diff --git a/crates/pdfs-cli/src/main.rs b/crates/pdfs-cli/src/main.rs index 4398340..06b81e3 100644 --- a/crates/pdfs-cli/src/main.rs +++ b/crates/pdfs-cli/src/main.rs @@ -11,6 +11,7 @@ use pdfs_core::auth; use pdfs_core::cache::ContentCache; use pdfs_core::config::AppDirs; use pdfs_core::control::{ + ErrorKind, RefreshScope, Request as CtlRequest, Response as CtlResponse, ShareEntryKind, SyncPhase, pending_summary, }; @@ -351,6 +352,19 @@ enum BookmarkCmd { Rm { token: String }, } +/// Render a daemon failure for a terminal. +/// +/// Offline is the one class worth rewording: the daemon's prose names whichever +/// call happened to hit the network, which tells someone at a shell nothing they +/// can act on. Every other class already says something specific, so it is +/// printed as sent. +fn cli_error(kind: ErrorKind, message: &str) -> String { + match kind { + ErrorKind::Offline => "offline: the Proton Drive API is unreachable".to_string(), + _ => message.to_string(), + } +} + fn main() -> Result<()> { tracing_subscriber::fmt() .with_env_filter( @@ -429,7 +443,7 @@ fn cmd_devices(action: DeviceCmd) -> Result<()> { ); } } - CtlResponse::Error { message } => bail!("{message}"), + CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), }, DeviceCmd::Rename { uid, name } => { @@ -489,7 +503,7 @@ fn cmd_sync(action: SyncCmd) -> Result<()> { } } } - CtlResponse::Error { message } => bail!("{message}"), + CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), }, SyncCmd::Rm { id, delete_remote } => { @@ -545,7 +559,7 @@ fn cmd_members(path: PathBuf) -> Result<()> { None => println!("public link: none"), } } - CtlResponse::Error { message } => bail!("{message}"), + CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) @@ -584,7 +598,7 @@ fn cmd_public_link(action: PublicLinkCmd) -> Result<()> { CtlResponse::PublicLink { link } => { println!("{}", link.url.as_deref().unwrap_or("(no url returned)")); } - CtlResponse::Error { message } => bail!("{message}"), + CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), }, PublicLinkCmd::Remove { path, id } => { @@ -626,7 +640,7 @@ fn cmd_shared() -> Result<()> { println!("{kind} {}{tags} [{}]", it.name, it.uid); } } - CtlResponse::Error { message } => bail!("{message}"), + CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) @@ -643,7 +657,7 @@ fn cmd_shared_with_me() -> Result<()> { println!("{kind} {:>12} {} [{}]", e.size, e.name, e.uid); } } - CtlResponse::Error { message } => bail!("{message}"), + CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) @@ -665,7 +679,7 @@ fn cmd_activity(limit: usize) -> Result<()> { println!("{mark} {when} {verb:<8} {}{detail}", a.target); } } - CtlResponse::Error { message } => bail!("{message}"), + CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) @@ -729,7 +743,7 @@ fn cmd_invitations(action: InvitationCmd) -> Result<()> { println!("{} shared {kind} \"{name}\" [{}]", i.inviter_email, i.id); } } - CtlResponse::Error { message } => bail!("{message}"), + CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), }, InvitationCmd::Accept { id } => { @@ -752,7 +766,7 @@ fn cmd_bookmarks(action: BookmarkCmd) -> Result<()> { println!("{name} {} [{}]", b.url, b.token); } } - CtlResponse::Error { message } => bail!("{message}"), + CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), }, BookmarkCmd::Add { url, password } => { @@ -773,7 +787,7 @@ fn cmd_bookmarks(action: BookmarkCmd) -> Result<()> { fn ok_or_bail(resp: CtlResponse) -> Result<()> { match resp { CtlResponse::Ok { message } => println!("{message}"), - CtlResponse::Error { message } => bail!("{message}"), + CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) @@ -956,7 +970,7 @@ fn cmd_pin(path: PathBuf) -> Result<()> { path: path_arg(&path)?, })? { CtlResponse::Ok { message } => println!("{message}"), - CtlResponse::Error { message } => bail!("{message}"), + CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) @@ -967,7 +981,7 @@ fn cmd_unpin(path: PathBuf) -> Result<()> { path: path_arg(&path)?, })? { CtlResponse::Ok { message } => println!("{message}"), - CtlResponse::Error { message } => bail!("{message}"), + CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) @@ -1015,7 +1029,7 @@ fn cmd_transfers() -> Result<()> { ); } } - CtlResponse::Error { message } => bail!("{message}"), + CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) @@ -1029,7 +1043,7 @@ fn cmd_pins() -> Result<()> { println!("{}", p.path); } } - CtlResponse::Error { message } => bail!("{message}"), + CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) @@ -1049,7 +1063,7 @@ fn cmd_ls(path: Option) -> Result<()> { println!("{kind}{pin} {:>12} {} [{}]", e.size, e.name, e.uid); } } - CtlResponse::Error { message } => bail!("{message}"), + CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) @@ -1088,7 +1102,7 @@ fn cmd_photos(limit: usize, offset: usize) -> Result<()> { println!("{} {} {thumb}", p.capture_time, p.uid); } } - CtlResponse::Error { message } => bail!("{message}"), + CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) @@ -1097,7 +1111,7 @@ fn cmd_photos(limit: usize, offset: usize) -> Result<()> { fn cmd_open_photo(uid: String) -> Result<()> { match control_request(CtlRequest::OpenPhoto { uid })? { CtlResponse::FilePath { path } => println!("{path}"), - CtlResponse::Error { message } => bail!("{message}"), + CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) @@ -1113,7 +1127,7 @@ fn cmd_search(query: String, limit: usize) -> Result<()> { println!("{kind}{pin} {:>12} {}", h.size, h.path); } } - CtlResponse::Error { message } => bail!("{message}"), + CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) @@ -1125,7 +1139,7 @@ fn cmd_rename(path: PathBuf, new_name: String) -> Result<()> { new_name, })? { CtlResponse::Ok { message } => println!("{message}"), - CtlResponse::Error { message } => bail!("{message}"), + CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) @@ -1137,7 +1151,7 @@ fn cmd_move(path: PathBuf, new_parent: PathBuf) -> Result<()> { new_parent: path_arg(&new_parent)?, })? { CtlResponse::Ok { message } => println!("{message}"), - CtlResponse::Error { message } => bail!("{message}"), + CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) @@ -1148,7 +1162,7 @@ fn cmd_rm(path: PathBuf) -> Result<()> { path: path_arg(&path)?, })? { CtlResponse::Ok { message } => println!("{message}"), - CtlResponse::Error { message } => bail!("{message}"), + CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) @@ -1163,7 +1177,7 @@ fn cmd_trash() -> Result<()> { println!("{kind} {:>12} {} [{}]", e.size, e.name, e.uid); } } - CtlResponse::Error { message } => bail!("{message}"), + CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) @@ -1172,7 +1186,7 @@ fn cmd_trash() -> Result<()> { fn cmd_restore(uids: Vec) -> Result<()> { match control_request(CtlRequest::Restore { uids })? { CtlResponse::Ok { message } => println!("{message}"), - CtlResponse::Error { message } => bail!("{message}"), + CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) @@ -1185,7 +1199,7 @@ fn cmd_delete_forever(uids: Vec) -> Result<()> { ))?; match control_request(CtlRequest::DeleteForever { uids })? { CtlResponse::Ok { message } => println!("{message}"), - CtlResponse::Error { message } => bail!("{message}"), + CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) @@ -1195,7 +1209,7 @@ fn cmd_empty_trash() -> Result<()> { confirm("Permanently delete everything in the trash? This cannot be undone.")?; match control_request(CtlRequest::EmptyTrash)? { CtlResponse::Ok { message } => println!("{message}"), - CtlResponse::Error { message } => bail!("{message}"), + CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) @@ -1214,7 +1228,7 @@ fn cmd_refresh(target: Option) -> Result<()> { }; match control_request(CtlRequest::Refresh { scope })? { CtlResponse::Ok { message } => println!("{message}"), - CtlResponse::Error { message } => bail!("{message}"), + CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) @@ -1240,7 +1254,7 @@ fn cmd_mkdir(parent: PathBuf, name: String) -> Result<()> { name, })? { CtlResponse::Ok { message } => println!("{message}"), - CtlResponse::Error { message } => bail!("{message}"), + CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) @@ -1263,7 +1277,7 @@ fn cmd_upload(sources: Vec, parent: PathBuf) -> Result<()> { sources: abs, })? { CtlResponse::Ok { message } => println!("{message}"), - CtlResponse::Error { message } => bail!("{message}"), + CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), other => bail!("unexpected response: {other:?}"), } Ok(()) diff --git a/crates/pdfs-core/src/control.rs b/crates/pdfs-core/src/control.rs index de713e7..8237806 100644 --- a/crates/pdfs-core/src/control.rs +++ b/crates/pdfs-core/src/control.rs @@ -874,8 +874,74 @@ pub enum Response { /// The daemon's recent activity, newest first (reply to /// [`Request::ListActivity`]). Activity { items: Vec }, - /// The request failed. - Error { message: String }, + /// The request failed. `message` is for the user; `kind` is for the code — + /// a front-end decides its copy and whether to offer a retry from `kind`, + /// never by matching on the text. + Error { + message: String, + #[serde(default)] + kind: ErrorKind, + }, +} + +impl Response { + /// Build a failure reply from a classified error, so the `kind` a + /// request-serving method decided survives the trip to the front-end. + pub fn error(e: crate::error::CoreError) -> Self { + Response::Error { + message: e.message, + kind: e.kind, + } + } +} + +/// What class of thing went wrong, as opposed to what it read like. +/// +/// The daemon answers most calls with prose assembled from whatever layer +/// failed (`"resolve path: ENOENT"`), which is fine to show and useless to +/// branch on. This is the branchable half: enough to pick the right copy, to +/// know whether retrying is meaningful, and to tell a caller's mistake apart +/// from an outage. +/// +/// Deliberately coarse. A variant earns its place by changing what a front-end +/// *does*, not by naming a distinct cause — anything finer belongs in `message`. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ErrorKind { + /// The API is unreachable and the request needed it. The cached tree is + /// still being served, so this is a "not right now", not a failure of the + /// thing the user asked for. + Offline, + /// No node at that path or uid. Usually means the front-end's listing is + /// stale, so the useful response is to reload it rather than to retry. + NotFound, + /// The account may not do that to that node — a viewer trying to write, a + /// share whose role was downgraded. Retrying changes nothing. + Denied, + /// The remote moved underneath the request: a name already taken, a + /// revision superseded. The caller has to decide, so never auto-retried. + Conflict, + /// The request itself was malformed — an empty name, a path with a `/` in + /// it, an unparseable uid. A bug in the caller, not a condition to retry. + Invalid, + /// The API was reached and refused, or the transfer broke. The one class + /// where an unchanged retry can legitimately succeed. + Remote, + /// Something on this machine failed: the database, the content cache, the + /// filesystem. Not the user's doing and not theirs to fix. + #[default] + Internal, +} + +impl ErrorKind { + /// Whether repeating the identical request could plausibly succeed. + /// + /// Drives whether a front-end offers "Try again" at all: offering it for a + /// [`NotFound`](Self::NotFound) or an [`Invalid`](Self::Invalid) teaches the + /// user that the button does nothing. + pub fn retryable(self) -> bool { + matches!(self, ErrorKind::Offline | ErrorKind::Remote) + } } /// Send one [`Request`] to the daemon listening on `socket` and read its @@ -1101,4 +1167,72 @@ mod tests { assert_eq!(line, serde_json::to_string(&back).unwrap()); } } + + /// A daemon built before `kind` existed sends `Error` without the field. + /// It must still parse, and land on the class that promises the least. + #[test] + fn an_error_without_a_kind_reads_as_internal() { + let wire = r#"{"Error":{"message":"something broke"}}"#; + let back: Response = serde_json::from_str(wire).unwrap(); + match back { + Response::Error { message, kind } => { + assert_eq!(message, "something broke"); + assert_eq!(kind, ErrorKind::Internal); + } + other => panic!("expected an error, got {other:?}"), + } + } + + #[test] + fn error_kind_survives_the_wire() { + for kind in [ + ErrorKind::Offline, + ErrorKind::NotFound, + ErrorKind::Denied, + ErrorKind::Conflict, + ErrorKind::Invalid, + ErrorKind::Remote, + ErrorKind::Internal, + ] { + let line = serde_json::to_string(&Response::Error { + message: "x".into(), + kind, + }) + .unwrap(); + let back: Response = serde_json::from_str(&line).unwrap(); + match back { + Response::Error { kind: got, .. } => assert_eq!(got, kind), + other => panic!("expected an error, got {other:?}"), + } + } + } + + /// Retry is offered to the user off the back of this, so it has to mean + /// "an identical request could work", not "this looks recoverable". + #[test] + fn only_offline_and_remote_are_worth_retrying() { + assert!(ErrorKind::Offline.retryable()); + assert!(ErrorKind::Remote.retryable()); + for kind in [ + ErrorKind::NotFound, + ErrorKind::Denied, + ErrorKind::Conflict, + ErrorKind::Invalid, + ErrorKind::Internal, + ] { + assert!(!kind.retryable(), "{kind:?} must not offer a retry"); + } + } + + #[test] + fn response_error_carries_a_core_errors_classification() { + let r = Response::error(crate::error::CoreError::not_found("no such file")); + match r { + Response::Error { message, kind } => { + assert_eq!(message, "no such file"); + assert_eq!(kind, ErrorKind::NotFound); + } + other => panic!("expected an error, got {other:?}"), + } + } } diff --git a/crates/pdfs-core/src/error.rs b/crates/pdfs-core/src/error.rs index b5950fc..e998dae 100644 --- a/crates/pdfs-core/src/error.rs +++ b/crates/pdfs-core/src/error.rs @@ -2,6 +2,8 @@ use thiserror::Error; +use crate::control::ErrorKind; + #[derive(Debug, Error)] pub enum Error { #[error("proton api: {0}")] @@ -34,3 +36,110 @@ pub enum Error { } pub type Result = std::result::Result; + +/// A failure on its way to a front-end: prose for the user, plus the +/// [`ErrorKind`] the front-end actually branches on. +/// +/// This is what the daemon's request-serving methods return, as opposed to +/// [`Error`] above, which is the plumbing error of the `pdfs-core` layer. The +/// two meet at the `From` impls below: a `rusqlite` or `io` failure carries no +/// information a user can act on, so it arrives here as +/// [`ErrorKind::Internal`] and keeps its text only for the log. +/// +/// Classification happens where the failure is *understood* — at the API +/// boundary that knows a 404 from a 403, at the path resolver that knows an +/// `ENOENT` — never at the call site, which by then has only a string. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CoreError { + pub kind: ErrorKind, + pub message: String, +} + +impl CoreError { + pub fn new(kind: ErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + } + } + + /// The API is unreachable. Carries fixed wording because there is only ever + /// one thing to say about it. + pub fn offline() -> Self { + Self::new(ErrorKind::Offline, "the Proton Drive API is unreachable") + } + + pub fn not_found(what: impl Into) -> Self { + Self::new(ErrorKind::NotFound, what) + } + + pub fn denied(what: impl Into) -> Self { + Self::new(ErrorKind::Denied, what) + } + + pub fn conflict(what: impl Into) -> Self { + Self::new(ErrorKind::Conflict, what) + } + + pub fn invalid(what: impl Into) -> Self { + Self::new(ErrorKind::Invalid, what) + } + + pub fn remote(what: impl Into) -> Self { + Self::new(ErrorKind::Remote, what) + } + + pub fn internal(what: impl Into) -> Self { + Self::new(ErrorKind::Internal, what) + } + + /// Prefix the message while keeping the classification. + /// + /// For adding the caller's context to an already-classified failure — the + /// kind was decided by whoever understood the error and must survive being + /// passed up. + pub fn context(mut self, what: &str) -> Self { + self.message = format!("{what}: {}", self.message); + self + } +} + +impl std::fmt::Display for CoreError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for CoreError {} + +impl From for CoreError { + fn from(e: rusqlite::Error) -> Self { + Self::internal(format!("database: {e}")) + } +} + +impl From for CoreError { + fn from(e: std::io::Error) -> Self { + Self::internal(format!("io: {e}")) + } +} + +impl From for CoreError { + fn from(e: serde_json::Error) -> Self { + Self::internal(format!("serialization: {e}")) + } +} + +impl From for CoreError { + fn from(e: Error) -> Self { + let kind = match &e { + Error::NotLoggedIn | Error::ReloginRequired => ErrorKind::Denied, + Error::Proton(_) => ErrorKind::Remote, + _ => ErrorKind::Internal, + }; + Self::new(kind, e.to_string()) + } +} + +/// The result every request-serving method in the daemon returns. +pub type CoreResult = std::result::Result; diff --git a/crates/pdfs-core/src/lib.rs b/crates/pdfs-core/src/lib.rs index 5479313..27dc6f0 100644 --- a/crates/pdfs-core/src/lib.rs +++ b/crates/pdfs-core/src/lib.rs @@ -12,7 +12,7 @@ pub mod localindex; pub mod service; pub mod shell; -pub use error::{Error, Result}; +pub use error::{CoreError, CoreResult, Error, Result}; // Re-export the SDK surface downstream crates need, so they depend on // `pdfs-core` rather than pinning the SDK directly. diff --git a/crates/pdfs-fuse/src/control.rs b/crates/pdfs-fuse/src/control.rs index d131d73..97cd835 100644 --- a/crates/pdfs-fuse/src/control.rs +++ b/crates/pdfs-fuse/src/control.rs @@ -15,6 +15,7 @@ use std::time::Instant; use std::os::unix::net::{UnixListener, UnixStream}; use std::path::{Path, PathBuf}; +use pdfs_core::{CoreError, CoreResult}; use pdfs_core::config::AppDirs; use pdfs_core::control::{ ActivityKind, PhotoMonth, RefreshScope, Request as CtlRequest, Response as CtlResponse, @@ -29,12 +30,12 @@ use super::{Core, count_noun, human_bytes, human_duration, parse_uid}; /// Turn a CLI-supplied path into a mountpoint-relative path. An absolute path /// must live under `mountpoint`; a relative path is taken as already relative to /// the mount root. -fn rel_to_mount(mountpoint: &Path, path: &str) -> Result { +fn rel_to_mount(mountpoint: &Path, path: &str) -> CoreResult { let p = Path::new(path); if p.is_absolute() { p.strip_prefix(mountpoint) .map(Path::to_path_buf) - .map_err(|_| format!("{path} is not under the mountpoint")) + .map_err(|_| CoreError::invalid(format!("{path} is not under the mountpoint"))) } else { Ok(p.to_path_buf()) } @@ -75,18 +76,18 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U Ok(name) => CtlResponse::Ok { message: format!("pinned {name}"), }, - Err(e) => CtlResponse::Error { message: e }, + Err(e) => CtlResponse::error(e), }, - Err(e) => CtlResponse::Error { message: e }, + Err(e) => CtlResponse::error(e), }, Ok(CtlRequest::Unpin { path }) => match rel_to_mount(mountpoint, &path) { Ok(rel) => match core.unpin(&rel) { Ok(name) => CtlResponse::Ok { message: format!("unpinned {name}"), }, - Err(e) => CtlResponse::Error { message: e }, + Err(e) => CtlResponse::error(e), }, - Err(e) => CtlResponse::Error { message: e }, + Err(e) => CtlResponse::error(e), }, Ok(CtlRequest::ListPins) => CtlResponse::Pins { pins: core.cache.list_pins(), @@ -94,9 +95,9 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U Ok(CtlRequest::ListDir { path }) => match rel_to_mount(mountpoint, &path) { Ok(rel) => match core.list_dir(&rel) { Ok(entries) => CtlResponse::Entries { entries }, - Err(e) => CtlResponse::Error { message: e }, + Err(e) => CtlResponse::error(e), }, - Err(e) => CtlResponse::Error { message: e }, + Err(e) => CtlResponse::error(e), }, Ok(CtlRequest::Refresh { scope }) => { let result = match &scope { @@ -116,7 +117,7 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U Ok(()) => CtlResponse::Ok { message: "refreshed".to_string(), }, - Err(e) => CtlResponse::Error { message: e }, + Err(e) => CtlResponse::error(e), } } Ok(CtlRequest::PhotosTimeline { @@ -136,7 +137,7 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U items: Vec::new(), counts: None, }, - Err(e) => CtlResponse::Error { message: e }, + Err(e) => CtlResponse::error(e), } } Ok(CtlRequest::PhotoMonths { kind }) => match core.db.photos_months(kind) { @@ -150,9 +151,7 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U }) .collect(), }, - Err(e) => CtlResponse::Error { - message: e.to_string(), - }, + Err(e) => CtlResponse::error(e.into()), }, Ok(CtlRequest::PhotoThumbs { uids }) => { let parsed: Vec = uids.iter().filter_map(|u| parse_uid(u)).collect(); @@ -165,11 +164,9 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U Ok(p) => CtlResponse::FilePath { path: p.display().to_string(), }, - Err(e) => CtlResponse::Error { message: e }, - }, - None => CtlResponse::Error { - message: format!("bad uid: {uid}"), + Err(e) => CtlResponse::error(e), }, + None => CtlResponse::error(CoreError::invalid(format!("bad uid: {uid}"))), }, Ok(CtlRequest::UploadPhoto { name, @@ -210,9 +207,7 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U message: format!("uploaded photo with uid {uid}"), } } - Err(e) => CtlResponse::Error { - message: format!("upload photo failed: {e}"), - }, + Err(e) => CtlResponse::error(CoreError::remote(format!("upload photo failed: {e}"))), } } Ok(CtlRequest::OpenFile { path }) => match rel_to_mount(mountpoint, &path) { @@ -220,20 +215,20 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U Ok(p) => CtlResponse::FilePath { path: p.display().to_string(), }, - Err(e) => CtlResponse::Error { message: e }, + Err(e) => CtlResponse::error(e), }, - Err(e) => CtlResponse::Error { message: e }, + Err(e) => CtlResponse::error(e), }, Ok(CtlRequest::Search { query, limit }) => match core.search(&query, limit) { Ok(hits) => CtlResponse::SearchResults { hits }, - Err(e) => CtlResponse::Error { message: e }, + Err(e) => CtlResponse::error(e), }, Ok(CtlRequest::SearchLocal { query, limit }) => match core.search_local(&query, limit) { Ok(hits) => CtlResponse::LocalResults { hits, indexing: core.indexing.load(Ordering::Relaxed), }, - Err(e) => CtlResponse::Error { message: e }, + Err(e) => CtlResponse::error(e), }, Ok(CtlRequest::Rename { path, new_name }) => match rel_to_mount(mountpoint, &path) { Ok(rel) => match core.rename(&rel, &new_name) { @@ -245,10 +240,10 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U } Err(e) => { core.log_activity(ActivityKind::Rename, &path, &e, false); - CtlResponse::Error { message: e } + CtlResponse::error(e) } }, - Err(e) => CtlResponse::Error { message: e }, + Err(e) => CtlResponse::error(e), }, Ok(CtlRequest::Move { path, new_parent }) => { match ( @@ -269,10 +264,10 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U } Err(e) => { core.log_activity(ActivityKind::Move, &path, &e, false); - CtlResponse::Error { message: e } + CtlResponse::error(e) } }, - (Err(e), _) | (_, Err(e)) => CtlResponse::Error { message: e }, + (Err(e), _) | (_, Err(e)) => CtlResponse::error(e), } } Ok(CtlRequest::Delete { path }) => match rel_to_mount(mountpoint, &path) { @@ -285,10 +280,10 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U } Err(e) => { core.log_activity(ActivityKind::Trash, &path, &e, false); - CtlResponse::Error { message: e } + CtlResponse::error(e) } }, - Err(e) => CtlResponse::Error { message: e }, + Err(e) => CtlResponse::error(e), }, Ok(CtlRequest::CreateFolder { parent, name }) => match rel_to_mount(mountpoint, &parent) { Ok(parent_rel) => match core.create_folder(&parent_rel, &name) { @@ -300,10 +295,10 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U } Err(e) => { core.log_activity(ActivityKind::CreateFolder, &name, &e, false); - CtlResponse::Error { message: e } + CtlResponse::error(e) } }, - Err(e) => CtlResponse::Error { message: e }, + Err(e) => CtlResponse::error(e), }, Ok(CtlRequest::UploadFile { parent, @@ -319,10 +314,10 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U } Err(e) => { core.log_activity(ActivityKind::Upload, &name, &e, false); - CtlResponse::Error { message: e } + CtlResponse::error(e) } }, - Err(e) => CtlResponse::Error { message: e }, + Err(e) => CtlResponse::error(e), }, Ok(CtlRequest::UploadPaths { parent, sources }) => { match rel_to_mount(mountpoint, &parent) { @@ -375,12 +370,12 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U message: format!("uploading {n} item(s)"), } } - Err(e) => CtlResponse::Error { message: e }, + Err(e) => CtlResponse::error(e), } } Ok(CtlRequest::ListTrash) => match core.list_trash() { Ok(entries) => CtlResponse::Entries { entries }, - Err(e) => CtlResponse::Error { message: e }, + Err(e) => CtlResponse::error(e), }, Ok(CtlRequest::Restore { uids }) => match core.restore(&uids) { Ok(n) => { @@ -389,7 +384,7 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U message: format!("restored {n} item(s)"), } } - Err(e) => CtlResponse::Error { message: e }, + Err(e) => CtlResponse::error(e), }, Ok(CtlRequest::DeleteForever { uids }) => match core.delete_forever(&uids) { Ok(n) => { @@ -403,7 +398,7 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U message: format!("permanently deleted {n} item(s)"), } } - Err(e) => CtlResponse::Error { message: e }, + Err(e) => CtlResponse::error(e), }, Ok(CtlRequest::EmptyTrash) => match core.empty_trash() { Ok(n) => { @@ -412,7 +407,7 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U message: format!("emptied trash ({n} item(s))"), } } - Err(e) => CtlResponse::Error { message: e }, + Err(e) => CtlResponse::error(e), }, Ok(CtlRequest::PurgeCache) => { let freed = core.cache.clear_unpinned(); @@ -439,29 +434,29 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U 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}"), - }, + Ok(Err(e)) => CtlResponse::error(CoreError::internal(format!( + "budget applied but config write failed: {e}" + ))), + Err(e) => CtlResponse::error(CoreError::internal(format!( + "budget applied but config unavailable: {e}" + ))), } } Ok(CtlRequest::ListDevices) => match core.list_devices() { Ok(items) => CtlResponse::Devices { items }, - Err(e) => CtlResponse::Error { message: e }, + Err(e) => CtlResponse::error(e), }, Ok(CtlRequest::RenameDevice { uid, name }) => match core.rename_device(&uid, &name) { Ok(()) => CtlResponse::Ok { message: format!("renamed device to {name}"), }, - Err(e) => CtlResponse::Error { message: e }, + Err(e) => CtlResponse::error(e), }, Ok(CtlRequest::DeleteDevice { uid }) => match core.delete_device(&uid) { Ok(()) => CtlResponse::Ok { message: "device deleted".to_string(), }, - Err(e) => CtlResponse::Error { message: e }, + Err(e) => CtlResponse::error(e), }, Ok(CtlRequest::AddSyncFolder { local_path }) => { // Registering the device and uploading a folder tree far outlasts the @@ -498,14 +493,14 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U } Ok(CtlRequest::ListSyncFolders) => match core.list_sync_folders() { Ok(items) => CtlResponse::SyncFolders { items }, - Err(e) => CtlResponse::Error { message: e }, + Err(e) => CtlResponse::error(e), }, Ok(CtlRequest::RemoveSyncFolder { id, delete_remote }) => { match core.remove_sync_folder(id, delete_remote) { Ok(()) => CtlResponse::Ok { message: "removed synced folder".to_string(), }, - Err(e) => CtlResponse::Error { message: e }, + Err(e) => CtlResponse::error(e), } } Ok(CtlRequest::SetSyncFolderMode { id, mode }) => { @@ -514,7 +509,7 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U core.log_activity(ActivityKind::Upload, &message, "", true); CtlResponse::Ok { message } } - Err(e) => CtlResponse::Error { message: e }, + Err(e) => CtlResponse::error(e), } } Ok(CtlRequest::SyncNow { id }) => { @@ -546,17 +541,17 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U } Err(e) => { core.log_activity(ActivityKind::Share, &path, &e, false); - CtlResponse::Error { message: e } + CtlResponse::error(e) } }, - Err(e) => CtlResponse::Error { message: e }, + Err(e) => CtlResponse::error(e), }, Ok(CtlRequest::ListShare { path }) => match rel_to_mount(mountpoint, &path) { Ok(rel) => match core.list_share(&rel) { Ok((entries, link)) => CtlResponse::Share { entries, link }, - Err(e) => CtlResponse::Error { message: e }, + Err(e) => CtlResponse::error(e), }, - Err(e) => CtlResponse::Error { message: e }, + Err(e) => CtlResponse::error(e), }, Ok(CtlRequest::UpdateShareRole { path, @@ -568,9 +563,9 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U Ok(()) => CtlResponse::Ok { message: format!("role updated to {role}"), }, - Err(e) => CtlResponse::Error { message: e }, + Err(e) => CtlResponse::error(e), }, - Err(e) => CtlResponse::Error { message: e }, + Err(e) => CtlResponse::error(e), }, Ok(CtlRequest::RemoveShareEntry { path, id, kind }) => { match rel_to_mount(mountpoint, &path) { @@ -581,9 +576,9 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U message: "removed".to_string(), } } - Err(e) => CtlResponse::Error { message: e }, + Err(e) => CtlResponse::error(e), }, - Err(e) => CtlResponse::Error { message: e }, + Err(e) => CtlResponse::error(e), } } Ok(CtlRequest::CreatePublicLink { @@ -599,10 +594,10 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U } Err(e) => { core.log_activity(ActivityKind::PublicLink, &path, &e, false); - CtlResponse::Error { message: e } + CtlResponse::error(e) } }, - Err(e) => CtlResponse::Error { message: e }, + Err(e) => CtlResponse::error(e), }, Ok(CtlRequest::RemovePublicLink { path, id }) => match rel_to_mount(mountpoint, &path) { Ok(rel) => match core.remove_public_link(&rel, &id) { @@ -612,13 +607,13 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U message: "public link removed".to_string(), } } - Err(e) => CtlResponse::Error { message: e }, + Err(e) => CtlResponse::error(e), }, - Err(e) => CtlResponse::Error { message: e }, + Err(e) => CtlResponse::error(e), }, Ok(CtlRequest::ListSharedWithMe) => match core.list_shared_with_me() { Ok(entries) => CtlResponse::Entries { entries }, - Err(e) => CtlResponse::Error { message: e }, + Err(e) => CtlResponse::error(e), }, Ok(CtlRequest::LeaveShared { uid }) => match core.leave_shared(&uid) { Ok(()) => { @@ -627,52 +622,51 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U message: "left shared item".to_string(), } } - Err(e) => CtlResponse::Error { message: e }, + Err(e) => CtlResponse::error(e), }, Ok(CtlRequest::ListInvitations) => match core.list_invitations() { Ok(items) => CtlResponse::Invitations { items }, - Err(e) => CtlResponse::Error { message: e }, + Err(e) => CtlResponse::error(e), }, Ok(CtlRequest::AcceptInvitation { id }) => match core.accept_invitation(&id) { Ok(()) => CtlResponse::Ok { message: "invitation accepted".to_string(), }, - Err(e) => CtlResponse::Error { message: e }, + Err(e) => CtlResponse::error(e), }, Ok(CtlRequest::RejectInvitation { id }) => match core.reject_invitation(&id) { Ok(()) => CtlResponse::Ok { message: "invitation rejected".to_string(), }, - Err(e) => CtlResponse::Error { message: e }, + Err(e) => CtlResponse::error(e), }, Ok(CtlRequest::ListBookmarks) => match core.list_bookmarks() { Ok(items) => CtlResponse::Bookmarks { items }, - Err(e) => CtlResponse::Error { message: e }, + Err(e) => CtlResponse::error(e), }, Ok(CtlRequest::CreateBookmark { url, password }) => { match core.create_bookmark(&url, password.as_deref()) { Ok(()) => CtlResponse::Ok { message: "bookmark saved".to_string(), }, - Err(e) => CtlResponse::Error { message: e }, + Err(e) => CtlResponse::error(e), } } Ok(CtlRequest::DeleteBookmark { token }) => match core.delete_bookmark(&token) { Ok(()) => CtlResponse::Ok { message: "bookmark removed".to_string(), }, - Err(e) => CtlResponse::Error { message: e }, + Err(e) => CtlResponse::error(e), }, Ok(CtlRequest::ListSharedByMe) => match core.list_shared_by_me() { Ok(items) => CtlResponse::SharedByMe { items }, - Err(e) => CtlResponse::Error { message: e }, + Err(e) => CtlResponse::error(e), }, Ok(CtlRequest::ListActivity { limit }) => CtlResponse::Activity { items: core.list_activity(limit), }, - Err(e) => CtlResponse::Error { - message: format!("bad request: {e}"), - }, + // The request did not parse: the caller sent something malformed. + Err(e) => CtlResponse::error(CoreError::invalid(format!("bad request: {e}"))), }; let mut out = match serde_json::to_vec(&response) { Ok(v) => v, diff --git a/crates/pdfs-fuse/src/devices.rs b/crates/pdfs-fuse/src/devices.rs index 465bd79..994501f 100644 --- a/crates/pdfs-fuse/src/devices.rs +++ b/crates/pdfs-fuse/src/devices.rs @@ -12,6 +12,7 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use parking_lot::Mutex; +use pdfs_core::{CoreError, CoreResult}; use pdfs_core::control::{ActivityKind, DeviceInfo, JobItem, SyncFolderInfo, SyncPhase, SyncProgress}; use pdfs_core::db::{StoredDevice, StoredSyncFolder}; use fuser::{Config, MountOption, Session}; @@ -31,11 +32,11 @@ impl Core { /// List the account's registered devices, flagging the one *this* machine /// syncs to so a front-end can treat it as more than another computer in the /// list — deleting it takes this machine's synced folders down with it. - pub(crate) fn list_devices(&self) -> Result, String> { + pub(crate) fn list_devices(&self) -> CoreResult> { let devices = self .rt .block_on(self.client.enumerate_devices()) - .map_err(|e| format!("list devices: {e}"))?; + .map_err(|e| CoreError::remote(format!("list devices: {e}")))?; // No cached device row yet means this machine syncs nothing, so none of // the listed devices is ours. let this_uid = self.db.device_get().ok().flatten().map(|d| d.uid); @@ -55,23 +56,23 @@ impl Core { } /// Rename a device by its uid. - pub(crate) fn rename_device(&self, uid: &str, name: &str) -> Result<(), String> { + pub(crate) fn rename_device(&self, uid: &str, name: &str) -> CoreResult<()> { if name.is_empty() { - return Err("device name must not be empty".to_string()); + return Err(CoreError::invalid("device name must not be empty")); } let device_uid = DeviceUid::from(uid); self.rt .block_on(self.client.rename_device(&device_uid, name)) - .map_err(|e| format!("rename device: {e}"))?; + .map_err(|e| CoreError::remote(format!("rename device: {e}")))?; Ok(()) } /// Delete (deregister) a device by its uid. - pub(crate) fn delete_device(&self, uid: &str) -> Result<(), String> { + pub(crate) fn delete_device(&self, uid: &str) -> CoreResult<()> { let device_uid = DeviceUid::from(uid); self.rt .block_on(self.client.delete_device(&device_uid)) - .map_err(|e| format!("delete device: {e}"))?; + .map_err(|e| CoreError::remote(format!("delete device: {e}")))?; Ok(()) } @@ -81,20 +82,20 @@ impl Core { /// it so restarts reuse the same device. Recovery matches an existing remote /// Linux device by name before creating a new one, so a lost local record /// doesn't orphan the device's root folder. - pub(crate) fn ensure_device(&self) -> Result { + pub(crate) fn ensure_device(&self) -> CoreResult { let name = this_hostname(); // Enumerate the remote devices once: used both to validate any cached // record and to recover an existing device by name. let remote = self .rt .block_on(self.client.enumerate_devices()) - .map_err(|e| format!("enumerate devices: {e}"))?; + .map_err(|e| CoreError::remote(format!("enumerate devices: {e}")))?; // A cached device is only trustworthy if it still exists remotely. A // device deleted from another client (or the web UI) leaves a stale row // whose root folder is gone, so creating folders under it fails with // "parent node is not a folder". Re-register in that case. - if let Some(dev) = self.db.device_get().map_err(|e| format!("db: {e:?}"))? { + if let Some(dev) = self.db.device_get().map_err(|e| CoreError::internal(format!("db: {e:?}")))? { if remote.iter().any(|d| d.uid.to_string() == dev.uid) { return Ok(dev); } @@ -117,7 +118,7 @@ impl Core { let d = self .rt .block_on(self.client.create_device(&name, DeviceType::Linux)) - .map_err(|e| format!("create device: {e}"))?; + .map_err(|e| CoreError::remote(format!("create device: {e}")))?; StoredDevice { uid: d.uid.to_string(), share_id: d.share_id.to_string(), @@ -127,7 +128,7 @@ impl Core { } } }; - self.db.device_set(&dev).map_err(|e| format!("db: {e:?}"))?; + self.db.device_set(&dev).map_err(|e| CoreError::internal(format!("db: {e:?}")))?; Ok(dev) } @@ -137,18 +138,18 @@ impl Core { &self, root_uid: &NodeUid, name: &str, - ) -> Result, String> { + ) -> CoreResult> { let uids = self .rt .block_on(self.client.enumerate_folder_children_node_uids(root_uid)) - .map_err(|e| format!("list device root: {e}"))?; + .map_err(|e| CoreError::remote(format!("list device root: {e}")))?; if uids.is_empty() { return Ok(None); } let nodes = self .rt .block_on(self.client.enumerate_nodes(&uids)) - .map_err(|e| format!("resolve device root children: {e}"))?; + .map_err(|e| CoreError::remote(format!("resolve device root children: {e}")))?; Ok(nodes .into_iter() .find(|n| n.is_folder() && !n.trashed && n.name == name) @@ -159,37 +160,37 @@ impl Core { /// needed, create a matching folder under the device root, upload the local /// tree into it once, and record the mapping. Phase 1 is a one-shot upload — /// the two-way engine (Phase 2) reconciles later changes. - pub(crate) fn add_sync_folder(&self, local: &Path) -> Result { + pub(crate) fn add_sync_folder(&self, local: &Path) -> CoreResult { let meta = - std::fs::metadata(local).map_err(|e| format!("stat {}: {e}", local.display()))?; + std::fs::metadata(local).map_err(|e| CoreError::internal(format!("stat {}: {e}", local.display())))?; if !meta.is_dir() { - return Err(format!("{} is not a directory", local.display())); + return Err(CoreError::invalid(format!("{} is not a directory", local.display()))); } let local = local .canonicalize() - .map_err(|e| format!("canonicalize {}: {e}", local.display()))?; + .map_err(|e| CoreError::internal(format!("canonicalize {}: {e}", local.display())))?; let local_str = local.to_string_lossy().to_string(); // Reject duplicates up front for a clear error (UNIQUE would also catch it). if self .db .sync_folder_list() - .map_err(|e| format!("db: {e:?}"))? + .map_err(|e| CoreError::internal(format!("db: {e:?}")))? .iter() .any(|f| f.local_path == local_str) { - return Err(format!("{} is already synced", local.display())); + return Err(CoreError::invalid(format!("{} is already synced", local.display()))); } let name = local .file_name() .and_then(|n| n.to_str()) - .ok_or_else(|| format!("unusable folder name: {}", local.display()))? + .ok_or_else(|| CoreError::invalid(format!("unusable folder name: {}", local.display())))? .to_string(); let device = self.ensure_device()?; let root_uid = parse_uid(&device.root_uid) - .ok_or_else(|| format!("bad device root uid: {}", device.root_uid))?; + .ok_or_else(|| CoreError::internal(format!("bad device root uid: {}", device.root_uid)))?; // The synced folder's remote root: the folder under the device root named // after the local basename. Reuse an existing one rather than creating a @@ -209,13 +210,13 @@ impl Core { self.client .create_folder(&root_uid, &name, Some(now_secs())), ) - .map_err(|e| format!("create device folder {name}: {e}"))?, + .map_err(|e| CoreError::remote(format!("create device folder {name}: {e}")))?, }; let id = self .db .sync_folder_add(&local_str, &remote_root.to_string(), &device.share_id) - .map_err(|e| format!("db: {e:?}"))?; + .map_err(|e| CoreError::internal(format!("db: {e:?}")))?; // Hand the initial upload to the sync engine: an empty baseline against a // full local tree reconciles as "upload everything", and the folder is @@ -226,18 +227,18 @@ impl Core { info!(local = %local.display(), id, "added sync folder"); self.db .sync_folder_get(id) - .map_err(|e| format!("db: {e:?}"))? - .ok_or_else(|| "sync folder vanished after insert".to_string()) + .map_err(|e| CoreError::internal(format!("db: {e:?}")))? + .ok_or_else(|| CoreError::not_found("sync folder vanished after insert")) } /// List this device's synced folders for the front-ends, each carrying the /// live progress of its pass when one is running. - pub(crate) fn list_sync_folders(&self) -> Result, String> { + pub(crate) fn list_sync_folders(&self) -> CoreResult> { let progress = self.sync_progress.lock(); Ok(self .db .sync_folder_list() - .map_err(|e| format!("db: {e:?}"))? + .map_err(|e| CoreError::internal(format!("db: {e:?}")))? .into_iter() .map(|f| { let live = progress.get(&f.id).cloned(); @@ -320,12 +321,12 @@ impl Core { /// Remove a synced folder from the sync set. `delete_remote` also deletes its /// folder under the device root; otherwise the cloud copy is left in place. - pub(crate) fn remove_sync_folder(&self, id: i64, delete_remote: bool) -> Result<(), String> { + pub(crate) fn remove_sync_folder(&self, id: i64, delete_remote: bool) -> CoreResult<()> { let folder = self .db .sync_folder_get(id) - .map_err(|e| format!("db: {e:?}"))? - .ok_or_else(|| format!("no synced folder with id {id}"))?; + .map_err(|e| CoreError::internal(format!("db: {e:?}")))? + .ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?; // An `ondemand` folder *is* a FUSE mount over its local path, so dropping // only the row would strand the mount: the path would keep serving a // folder the daemon no longer tracks, and nothing would ever unmount it. @@ -347,9 +348,9 @@ impl Core { if !self .db .sync_folder_remove(id) - .map_err(|e| format!("db: {e:?}"))? + .map_err(|e| CoreError::internal(format!("db: {e:?}")))? { - return Err(format!("no synced folder with id {id}")); + return Err(CoreError::invalid(format!("no synced folder with id {id}"))); } // Stop watching the folder we just dropped. let _ = self.sync_tx.send(sync::SyncMsg::Rewatch); @@ -394,22 +395,22 @@ impl Core { /// intent is recorded and the engine applies it at the end of the pass already /// running — which, seeing a queued `ondemand`, also stops pulling down files /// it is about to evict ([`Core::push_pass`]). - pub(crate) fn request_sync_folder_mode(&self, id: i64, mode: &str) -> Result { + pub(crate) fn request_sync_folder_mode(&self, id: i64, mode: &str) -> CoreResult { if mode != "mirror" && mode != "ondemand" { - return Err(format!("unknown mode {mode:?} (want mirror|ondemand)")); + return Err(CoreError::invalid(format!("unknown mode {mode:?} (want mirror|ondemand)"))); } let folder = self .db .sync_folder_get(id) - .map_err(|e| format!("db: {e:?}"))? - .ok_or_else(|| format!("no synced folder with id {id}"))?; + .map_err(|e| CoreError::internal(format!("db: {e:?}")))? + .ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?; if folder.mode == mode { // Already there. A queued switch the other way is the user changing // their mind back before it landed, so withdraw it. if folder.pending_mode.is_some() { self.db .sync_folder_set_pending_mode(id, None) - .map_err(|e| format!("db: {e:?}"))?; + .map_err(|e| CoreError::internal(format!("db: {e:?}")))?; let _ = self.sync_tx.send(sync::SyncMsg::Rewatch); return Ok(format!("staying {mode}")); } @@ -423,7 +424,7 @@ impl Core { Err(SwitchBlocked::NotNow) => { self.db .sync_folder_set_pending_mode(id, Some(mode)) - .map_err(|e| format!("db: {e:?}"))?; + .map_err(|e| CoreError::internal(format!("db: {e:?}")))?; let _ = self.sync_tx.send(sync::SyncMsg::Reconcile(id)); Ok(match mode { "ondemand" => format!( @@ -436,7 +437,7 @@ impl Core { ), }) } - Err(SwitchBlocked::Failed(e)) => Err(e), + Err(SwitchBlocked::Failed(e)) => Err(CoreError::internal(e)), } } @@ -542,7 +543,7 @@ impl Core { let session = self .spawn_ondemand_mount(&local, root) - .map_err(SwitchBlocked::Failed)?; + .map_err(|e| SwitchBlocked::Failed(e.to_string()))?; self.mounts.lock().insert(id, session); // Persist the mode only now that the mount is actually up. Writing it // first would strand the folder on any failure below: the engine skips @@ -587,7 +588,7 @@ impl Core { /// Spawn a secondary FUSE session rooted at `root` over `local` on a forked /// inode space. Clears any stale kernel mount first (a crashed run can leave /// one, which would fail the fresh mount with EBUSY). - pub(crate) fn spawn_ondemand_mount(&self, local: &Path, root: Node) -> Result { + pub(crate) fn spawn_ondemand_mount(&self, local: &Path, root: Node) -> CoreResult { clear_stale_mount(local); let mut config = Config::default(); config.mount_options = vec![ @@ -598,7 +599,7 @@ impl Core { let fs = ProtonFs::new(self.fork_state(), root); Session::new(fs, local, &config) .and_then(|s| s.spawn()) - .map_err(|e| format!("mount {}: {e}", local.display())) + .map_err(|e| CoreError::internal(format!("mount {}: {e}", local.display()))) } /// Re-establish FUSE mounts for folders left in `ondemand` mode across a daemon diff --git a/crates/pdfs-fuse/src/drain.rs b/crates/pdfs-fuse/src/drain.rs index 579b3c7..1d07015 100644 --- a/crates/pdfs-fuse/src/drain.rs +++ b/crates/pdfs-fuse/src/drain.rs @@ -24,6 +24,7 @@ use std::fs::File; use std::path::{Path, PathBuf}; use std::sync::atomic::Ordering; +use pdfs_core::CoreError; use pdfs_core::cache::StagedWrite; use pdfs_core::control::{ActivityKind, TransferDirection}; use pdfs_core::db::{OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PendingOp}; @@ -394,7 +395,7 @@ impl Core { local: &NodeUid, real: &NodeUid, ) -> Result<(), Box> { - let node = self.fetch_node(real).map_err(|e| format!("{e:?}"))?; + let node = self.fetch_node(real).map_err(|e| CoreError::remote(format!("{e:?}")))?; // Repoints queued children and node rows, and drops the placeholder row. self.db .remap_local_uid(&local.to_string(), &real.to_string())?; @@ -675,7 +676,7 @@ impl Core { meta.base_size, &written, ) - .map_err(|e| format!("gap-fill from base failed: {e:?}"))?; + .map_err(|e| CoreError::remote(format!("gap-fill from base failed: {e:?}")))?; } let name = { diff --git a/crates/pdfs-fuse/src/lib.rs b/crates/pdfs-fuse/src/lib.rs index 03c92a8..fc9f1a9 100644 --- a/crates/pdfs-fuse/src/lib.rs +++ b/crates/pdfs-fuse/src/lib.rs @@ -49,6 +49,7 @@ use fuser::{ RenameFlags, ReplyAttr, ReplyCreate, ReplyData, ReplyDirectory, ReplyEmpty, ReplyEntry, ReplyOpen, ReplyWrite, Request, Session, TimeOrNow, WriteFlags, }; +use pdfs_core::{CoreError, CoreResult}; use pdfs_core::cache::{BLOCK_SIZE, Baseline, ContentCache, StagedWrite}; use pdfs_core::config::AppDirs; use pdfs_core::control::{ @@ -819,6 +820,33 @@ impl Core { Ok((ino, uid)) } + /// [`resolve_path`](Self::resolve_path) for the request-serving side. + /// + /// The kernel-facing resolver answers in `Errno` because that is what the + /// FUSE reply needs. A front-end needs the opposite: `{:?}` of a libc + /// constant is not something to show a user, and "does not exist" and "the + /// API is down" want different copy and different buttons. This is the one + /// place that knows enough to tell them apart, so it is the place that does. + fn resolve(&self, rel: &Path) -> CoreResult<(u64, NodeUid)> { + self.resolve_path(rel).map_err(|e| { + let what = rel.display(); + // `Errno` is neither `PartialEq` nor structural-match, so this + // compares the raw codes rather than the constants. + match e.code() { + libc::ENOENT => CoreError::not_found(format!("no such file or folder: {what}")), + libc::EACCES | libc::EPERM => { + CoreError::denied(format!("not allowed to read {what}")) + } + // The resolver walks the tree lazily, so a cold path needs the + // API. Offline that surfaces as EIO, which on its own would read + // to the user as a broken file rather than a missing network. + libc::EIO if !self.online.load(Ordering::Relaxed) => CoreError::offline(), + libc::EINVAL => CoreError::invalid(format!("not a usable path: {what}")), + _ => CoreError::remote(format!("could not resolve {what}: {e:?}")), + } + }) + } + /// Fetch a single node's current metadata from the remote. fn fetch_node(&self, uid: &NodeUid) -> Result { match self.fetch_node_remote(uid) { @@ -1811,13 +1839,11 @@ impl Core { /// Pin the node at mountpoint-relative `rel`. A file downloads its full /// plaintext into the content cache; a folder records a recursive pin and /// downloads every descendant file (selective sync). Returns a human message. - fn pin(&self, rel: &Path) -> Result { - let (ino, uid) = self - .resolve_path(rel) - .map_err(|e| format!("resolve path: {e:?}"))?; + fn pin(&self, rel: &Path) -> CoreResult { + let (ino, uid) = self.resolve(rel)?; let (name, is_folder, mtime, size) = { let st = self.state.lock(); - let e = st.entries.get(&ino).ok_or("node vanished")?; + let e = st.entries.get(&ino).ok_or_else(|| CoreError::not_found("node vanished"))?; ( e.node.name.clone(), e.node.is_folder(), @@ -1830,19 +1856,19 @@ impl Core { // exempt before we start filling the cache with the subtree. self.cache .add_pin(&uid, rel, true) - .map_err(|e| format!("pin: {e}"))?; + .map_err(|e| CoreError::remote(format!("pin: {e}")))?; let n = self.pin_subtree(ino)?; return Ok(format!("{name} ({n} files)")); } let bytes = self .download_file_tracked(&uid, &name, size) - .map_err(|e| format!("download: {e}"))?; + .map_err(|e| CoreError::remote(format!("download: {e}")))?; self.cache .store(&uid, mtime, size, &bytes) - .map_err(|e| format!("cache store: {e}"))?; + .map_err(|e| CoreError::internal(format!("cache store: {e}")))?; self.cache .add_pin(&uid, rel, false) - .map_err(|e| format!("pin: {e}"))?; + .map_err(|e| CoreError::remote(format!("pin: {e}")))?; Ok(name) } @@ -1850,12 +1876,12 @@ impl Core { /// returning the count cached (already-fresh blobs counted, not re-fetched). /// Walks the tree depth-first, enumerating each folder so a cold subtree is /// fully discovered; the lock is dropped before each network download. - fn pin_subtree(&self, ino: u64) -> Result { + fn pin_subtree(&self, ino: u64) -> CoreResult { let mut files: Vec<(NodeUid, String, i64, u64)> = Vec::new(); let mut stack = vec![ino]; while let Some(dir) = stack.pop() { self.ensure_children(dir) - .map_err(|e| format!("enumerate: {e:?}"))?; + .map_err(|e| CoreError::remote(format!("enumerate: {e:?}")))?; let st = self.state.lock(); if let Some(kids) = st.children.get(&dir) { for &k in kids { @@ -1924,10 +1950,8 @@ impl Core { /// Unpin the node at `rel`, evicting its cached content. For a folder, also /// evicts every descendant's cached blob (the subtree is no longer kept). - fn unpin(&self, rel: &Path) -> Result { - let (ino, uid) = self - .resolve_path(rel) - .map_err(|e| format!("resolve path: {e:?}"))?; + fn unpin(&self, rel: &Path) -> CoreResult { + let (ino, uid) = self.resolve(rel)?; let (name, is_folder) = { let st = self.state.lock(); st.entries @@ -1937,7 +1961,7 @@ impl Core { }; self.cache .remove_pin(&uid) - .map_err(|e| format!("unpin: {e}"))?; + .map_err(|e| CoreError::remote(format!("unpin: {e}")))?; // A recursively-pinned folder's descendants were eviction-exempt; now // that the pin is gone, reclaim their blobs eagerly instead of waiting // for budget pressure. Descendants come from the DB node tree. @@ -1963,12 +1987,10 @@ impl Core { /// List the directory at mountpoint-relative `rel` for the in-app browser: /// the same lazy remote enumeration `readdir` uses, projected into /// serializable [`DirEntry`]s (with per-file pin state). - fn list_dir(&self, rel: &Path) -> Result, String> { - let (ino, _uid) = self - .resolve_path(rel) - .map_err(|e| format!("resolve path: {e:?}"))?; + fn list_dir(&self, rel: &Path) -> CoreResult> { + let (ino, _uid) = self.resolve(rel)?; self.ensure_children(ino) - .map_err(|e| format!("enumerate: {e:?}"))?; + .map_err(|e| CoreError::remote(format!("enumerate: {e:?}")))?; // Snapshot the listing, then drop the lock before touching the on-disk // pin registry so a slow disk read doesn't block FUSE metadata ops. let rows: Vec<(String, bool, u64, i64, NodeUid)> = { @@ -2011,11 +2033,11 @@ impl Core { /// Full-text search node names against the local SQLite index, mapping each /// DB hit to the wire [`SearchHit`] (resolving live pin state from the cache, /// which the DB doesn't track). Pure local lookup — never hits the network. - fn search(&self, query: &str, limit: usize) -> Result, String> { + fn search(&self, query: &str, limit: usize) -> CoreResult> { let hits = self .db .search(query, limit) - .map_err(|e| format!("search: {e}"))?; + .map_err(|e| CoreError::remote(format!("search: {e}")))?; Ok(hits .into_iter() .map(|h| SearchHit { @@ -2033,11 +2055,11 @@ impl Core { /// Search the index of files on this machine (outside Drive), built by the /// background scanner in [`run_local_index`]. Pure local lookup, never hits /// the network — and never touches the FUSE mount, which the scanner excludes. - fn search_local(&self, query: &str, limit: usize) -> Result, String> { + fn search_local(&self, query: &str, limit: usize) -> CoreResult> { let hits = self .db .search_local(query, limit) - .map_err(|e| format!("local search: {e}"))?; + .map_err(|e| CoreError::remote(format!("local search: {e}")))?; Ok(hits .into_iter() .map(|h| LocalHit { @@ -2075,15 +2097,13 @@ impl Core { /// into the content cache, returning its on-disk path (served from cache /// when a fresh blob already exists). Lets a front-end open the file with /// the user's default application without pinning it. - fn open_file(&self, rel: &Path) -> Result { - let (ino, uid) = self - .resolve_path(rel) - .map_err(|e| format!("resolve path: {e:?}"))?; + fn open_file(&self, rel: &Path) -> CoreResult { + let (ino, uid) = self.resolve(rel)?; let (name, mtime, size) = { let st = self.state.lock(); - let e = st.entries.get(&ino).ok_or("node vanished")?; + let e = st.entries.get(&ino).ok_or_else(|| CoreError::not_found("node vanished"))?; if !e.node.is_file() { - return Err("not a regular file".into()); + return Err(CoreError::invalid("not a regular file")); } ( e.node.name.clone(), @@ -2096,10 +2116,10 @@ impl Core { } let bytes = self .download_file_tracked(&uid, &name, size) - .map_err(|e| format!("download: {e}"))?; + .map_err(|e| CoreError::remote(format!("download: {e}")))?; self.cache .store(&uid, mtime, size, &bytes) - .map_err(|e| format!("cache store: {e}"))?; + .map_err(|e| CoreError::internal(format!("cache store: {e}")))?; Ok(self.cache.content_path(&uid)) } @@ -2118,16 +2138,14 @@ impl Core { /// 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 { + fn rename(&self, rel: &Path, new_name: &str) -> CoreResult { if new_name.is_empty() || new_name.contains('/') { - return Err(format!("invalid name: {new_name:?}")); + return Err(CoreError::invalid(format!("invalid name: {new_name:?}"))); } - let (_ino, uid) = self - .resolve_path(rel) - .map_err(|e| format!("resolve path: {e:?}"))?; + let (_ino, uid) = self.resolve(rel)?; self.rt .block_on(self.client.rename_node(&uid, new_name, None)) - .map_err(|e| format!("rename: {e}"))?; + .map_err(|e| CoreError::remote(format!("rename: {e}")))?; self.state.lock().forget(&uid); self.invalidate_parent_listing(rel); Ok(new_name.to_string()) @@ -2136,16 +2154,14 @@ impl Core { /// 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:?}"))?; + fn move_to(&self, rel: &Path, new_parent_rel: &Path) -> CoreResult { + let (_ino, uid) = self.resolve(rel)?; let (pino, new_parent_uid) = self .resolve_path(new_parent_rel) - .map_err(|e| format!("resolve new parent: {e:?}"))?; + .map_err(|e| CoreError::remote(format!("resolve new parent: {e:?}")))?; self.rt .block_on(self.client.move_node(&uid, &new_parent_uid)) - .map_err(|e| format!("move: {e}"))?; + .map_err(|e| CoreError::remote(format!("move: {e}")))?; let name = self .state .lock() @@ -2159,13 +2175,11 @@ impl Core { /// 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:?}"))?; + fn delete(&self, rel: &Path) -> CoreResult { + let (_ino, uid) = self.resolve(rel)?; self.rt .block_on(self.client.trash_nodes(std::slice::from_ref(&uid))) - .map_err(|e| format!("trash: {e}"))?; + .map_err(|e| CoreError::remote(format!("trash: {e}")))?; let name = self .state .lock() @@ -2189,7 +2203,7 @@ impl Core { /// back at DB speed and is refreshed in the background past [`TRASH_TTL`]. /// Our own trash mutations invalidate it outright (see /// [`Core::invalidate_trash`]), so the TTL only covers changes made elsewhere. - fn list_trash(&self) -> Result, String> { + fn list_trash(&self) -> CoreResult> { let stale = self.listing_stale(TRASH_SYNCED_MS, TRASH_TTL); // Never fetched: this request has to wait for it. if self.db.state_i64(TRASH_SYNCED_MS).ok().flatten().is_none() { @@ -2201,7 +2215,7 @@ impl Core { Ok(self .db .trash_list() - .map_err(|e| e.to_string())? + .map_err(CoreError::from)? .into_iter() .map(|item| DirEntry { name: item.name, @@ -2219,19 +2233,19 @@ impl Core { } /// Re-fetch the trash listing from the server and persist it. - async fn refresh_trash(&self) -> Result<(), String> { + async fn refresh_trash(&self) -> CoreResult<()> { let uids = self .client .enumerate_trash_node_uids() .await - .map_err(|e| format!("enumerate trash: {e}"))?; + .map_err(|e| CoreError::remote(format!("enumerate trash: {e}")))?; let nodes = if uids.is_empty() { Vec::new() } else { self.client .enumerate_nodes(&uids) .await - .map_err(|e| format!("enumerate nodes: {e}"))? + .map_err(|e| CoreError::remote(format!("enumerate nodes: {e}")))? }; let items: Vec = nodes .into_iter() @@ -2243,7 +2257,7 @@ impl Core { mtime: node.modification_time, }) .collect(); - self.db.trash_replace(&items).map_err(|e| e.to_string())?; + self.db.trash_replace(&items).map_err(CoreError::from)?; let _ = self.db.set_state_i64(TRASH_SYNCED_MS, now_ms()); Ok(()) } @@ -2279,10 +2293,8 @@ impl Core { /// Drop one folder's cached child listing (`rel` is mountpoint-relative), so /// the next `ListDir`/`readdir` re-enumerates it from the server. Backs /// [`CtlRequest::Refresh`] with a [`RefreshScope::Dir`] scope. - fn refresh_dir(&self, rel: &Path) -> Result<(), String> { - let (ino, uid) = self - .resolve_path(rel) - .map_err(|e| format!("resolve path: {e:?}"))?; + fn refresh_dir(&self, rel: &Path) -> CoreResult<()> { + let (ino, uid) = self.resolve(rel)?; // Clear the DB flag directly rather than through // `State::invalidate_listing`, which no-ops when the folder has no // in-memory listing to drop — exactly the case for a folder hydrated @@ -2290,19 +2302,19 @@ impl Core { // listing worth refreshing. self.db .set_listed(&uid, false) - .map_err(|e| format!("db: {e:?}"))?; + .map_err(|e| CoreError::internal(format!("db: {e:?}")))?; self.state.lock().children.remove(&ino); Ok(()) } /// Parse wire uids (`volume~link`) into [`NodeUid`]s, rejecting the whole /// batch if any is malformed — a partial trash mutation is worse than none. - fn parse_uids(uids: &[String]) -> Result, String> { + fn parse_uids(uids: &[String]) -> CoreResult> { if uids.is_empty() { - return Err("no nodes given".to_string()); + return Err(CoreError::invalid("no nodes given")); } uids.iter() - .map(|u| parse_uid(u).ok_or_else(|| format!("invalid uid: {u}"))) + .map(|u| parse_uid(u).ok_or_else(|| CoreError::invalid(format!("invalid uid: {u}")))) .collect() } @@ -2310,18 +2322,18 @@ impl Core { /// are read *before* the restore — a restored node reappears in a listing the /// daemon may already have cached, so each destination folder is invalidated /// and re-enumerated on next access. - fn restore(&self, uids: &[String]) -> Result { + fn restore(&self, uids: &[String]) -> CoreResult { let parsed = Self::parse_uids(uids)?; let parents: Vec = self .rt .block_on(self.client.enumerate_nodes(&parsed)) - .map_err(|e| format!("enumerate nodes: {e}"))? + .map_err(|e| CoreError::remote(format!("enumerate nodes: {e}")))? .into_iter() .filter_map(|n| n.parent_uid) .collect(); self.rt .block_on(self.client.restore_nodes(&parsed)) - .map_err(|e| format!("restore: {e}"))?; + .map_err(|e| CoreError::remote(format!("restore: {e}")))?; { let mut st = self.state.lock(); for parent in parents { @@ -2336,11 +2348,11 @@ impl Core { /// Permanently delete trashed nodes. Irreversible on the server; locally it /// drops any metadata and cached content the node still owns. - fn delete_forever(&self, uids: &[String]) -> Result { + fn delete_forever(&self, uids: &[String]) -> CoreResult { let parsed = Self::parse_uids(uids)?; self.rt .block_on(self.client.delete_nodes(&parsed)) - .map_err(|e| format!("delete: {e}"))?; + .map_err(|e| CoreError::remote(format!("delete: {e}")))?; self.drop_local(&parsed); self.invalidate_trash(); Ok(parsed.len()) @@ -2349,14 +2361,14 @@ impl Core { /// Permanently delete everything in the trash. The uids are listed first so /// the blobs of items trashed by *another* client — which this daemon may /// still hold in its cache — are reclaimed too, not just the ones it trashed. - fn empty_trash(&self) -> Result { + fn empty_trash(&self) -> CoreResult { let uids = self .rt .block_on(self.client.enumerate_trash_node_uids()) - .map_err(|e| format!("enumerate trash: {e}"))?; + .map_err(|e| CoreError::remote(format!("enumerate trash: {e}")))?; self.rt .block_on(self.client.empty_trash()) - .map_err(|e| format!("empty trash: {e}"))?; + .map_err(|e| CoreError::remote(format!("empty trash: {e}")))?; self.drop_local(&uids); self.invalidate_trash(); Ok(uids.len()) @@ -2378,25 +2390,23 @@ impl Core { /// 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 { + fn create_folder(&self, parent_rel: &Path, name: &str) -> CoreResult { if name.is_empty() || name.contains('/') { - return Err(format!("invalid name: {name:?}")); + return Err(CoreError::invalid(format!("invalid name: {name:?}"))); } - let (pino, parent_uid) = self - .resolve_path(parent_rel) - .map_err(|e| format!("resolve path: {e:?}"))?; + let (pino, parent_uid) = self.resolve(parent_rel)?; self.ensure_children(pino) - .map_err(|e| format!("enumerate: {e:?}"))?; + .map_err(|e| CoreError::remote(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}"))?; + .map_err(|e| CoreError::remote(format!("create folder: {e}")))?; let node = self .fetch_node(&new_uid) - .map_err(|e| format!("fetch node: {e:?}"))?; + .map_err(|e| CoreError::remote(format!("fetch node: {e:?}")))?; let mut st = self.state.lock(); let ino = st.intern(pino, node); if let Some(kids) = st.children.get_mut(&pino) @@ -2409,15 +2419,13 @@ impl Core { /// 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 { + fn upload(&self, parent_rel: &Path, name: &str, bytes: &[u8]) -> CoreResult { if name.is_empty() || name.contains('/') { - return Err(format!("invalid name: {name:?}")); + return Err(CoreError::invalid(format!("invalid name: {name:?}"))); } - let (pino, parent_uid) = self - .resolve_path(parent_rel) - .map_err(|e| format!("resolve path: {e:?}"))?; + let (pino, parent_uid) = self.resolve(parent_rel)?; self.ensure_children(pino) - .map_err(|e| format!("enumerate: {e:?}"))?; + .map_err(|e| CoreError::remote(format!("enumerate: {e:?}")))?; let guard = self .transfers .begin(name, "", TransferDirection::Upload, bytes.len() as u64); @@ -2434,11 +2442,11 @@ impl Core { None, false, )) - .map_err(|e| format!("upload: {e}"))?; + .map_err(|e| CoreError::remote(format!("upload: {e}")))?; drop(guard); let node = self .fetch_node(&new_uid) - .map_err(|e| format!("fetch node: {e:?}"))?; + .map_err(|e| CoreError::remote(format!("fetch node: {e:?}")))?; let mut st = self.state.lock(); let ino = st.intern(pino, node); if let Some(kids) = st.children.get_mut(&pino) @@ -2457,12 +2465,10 @@ impl Core { /// thread — a large tree far outlasts the control socket's read timeout — so /// it reports only a summary for the log. Individual failures are counted and /// logged rather than aborting the whole batch. - fn upload_paths(&self, parent_rel: &Path, sources: &[PathBuf]) -> Result { - let (pino, parent_uid) = self - .resolve_path(parent_rel) - .map_err(|e| format!("resolve path: {e:?}"))?; + fn upload_paths(&self, parent_rel: &Path, sources: &[PathBuf]) -> CoreResult { + let (pino, parent_uid) = self.resolve(parent_rel)?; self.ensure_children(pino) - .map_err(|e| format!("enumerate: {e:?}"))?; + .map_err(|e| CoreError::remote(format!("enumerate: {e:?}")))?; // Phase 1 (sequential): build the remote folder skeleton and collect the // flat list of files to upload. Folders must exist before their children, @@ -2552,12 +2558,12 @@ impl Core { pino: u64, parent_uid: &NodeUid, name: &str, - ) -> Result<(u64, NodeUid), String> { + ) -> CoreResult<(u64, NodeUid)> { if name.is_empty() || name.contains('/') { - return Err(format!("invalid folder name: {name:?}")); + return Err(CoreError::invalid(format!("invalid folder name: {name:?}"))); } self.ensure_children(pino) - .map_err(|e| format!("enumerate: {e:?}"))?; + .map_err(|e| CoreError::remote(format!("enumerate: {e:?}")))?; { let st = self.state.lock(); if let Some(kids) = st.children.get(&pino) { @@ -2577,10 +2583,10 @@ impl Core { self.client .create_folder(parent_uid, name, Some(now_secs())), ) - .map_err(|e| format!("create folder {name}: {e}"))?; + .map_err(|e| CoreError::remote(format!("create folder {name}: {e}")))?; let node = self .fetch_node(&new_uid) - .map_err(|e| format!("fetch node: {e:?}"))?; + .map_err(|e| CoreError::remote(format!("fetch node: {e:?}")))?; let mut st = self.state.lock(); let ino = st.intern(pino, node); if let Some(kids) = st.children.get_mut(&pino) @@ -2605,13 +2611,13 @@ impl Core { tasks: &mut Vec, folders: &mut usize, job: &JobGuard, - ) -> Result<(), String> { + ) -> CoreResult<()> { let meta = - std::fs::symlink_metadata(src).map_err(|e| format!("stat {}: {e}", src.display()))?; + std::fs::symlink_metadata(src).map_err(|e| CoreError::internal(format!("stat {}: {e}", src.display())))?; let name = src .file_name() .and_then(|n| n.to_str()) - .ok_or_else(|| format!("unusable name: {}", src.display()))? + .ok_or_else(|| CoreError::invalid(format!("unusable name: {}", src.display())))? .to_string(); if meta.is_dir() { @@ -2619,7 +2625,7 @@ impl Core { let (child_ino, child_uid) = self.ensure_remote_folder(pino, parent_uid, &name)?; *folders += 1; let mut entries: Vec = std::fs::read_dir(src) - .map_err(|e| format!("read dir {}: {e}", src.display()))? + .map_err(|e| CoreError::internal(format!("read dir {}: {e}", src.display())))? .filter_map(|e| e.ok()) .map(|e| e.path()) .collect(); @@ -2636,7 +2642,7 @@ impl Core { job.detail(&name); } else if meta.is_file() { if name.contains('/') { - return Err(format!("invalid file name: {name:?}")); + return Err(CoreError::invalid(format!("invalid file name: {name:?}"))); } tasks.push(UploadTask { parent_ino: pino, @@ -2660,14 +2666,16 @@ impl Core { &self, kind: ActivityKind, target: impl Into, - detail: impl Into, + // `Display` rather than `Into` so a classified `CoreError` can be + // logged as-is, without the caller flattening it first. + detail: impl std::fmt::Display, ok: bool, ) { let entry = ActivityEntry { time: now_secs(), kind, target: target.into(), - detail: detail.into(), + detail: detail.to_string(), ok, }; if let Err(e) = self.db.activity_add(&entry) { @@ -2773,12 +2781,12 @@ fn role_to_str(role: MemberRole) -> &'static str { /// Parse a wire role string into a [`MemberRole`]. "inherited" is read-only and /// rejected here, since it cannot be sent when inviting or updating. -fn role_from_str(role: &str) -> Result { +fn role_from_str(role: &str) -> CoreResult { match role.to_lowercase().as_str() { "viewer" => Ok(MemberRole::Viewer), "editor" => Ok(MemberRole::Editor), "admin" => Ok(MemberRole::Admin), - other => Err(format!("invalid role: {other}")), + other => Err(CoreError::invalid(format!("invalid role: {other}"))), } } diff --git a/crates/pdfs-fuse/src/photos.rs b/crates/pdfs-fuse/src/photos.rs index 3cf4fd4..bb9792c 100644 --- a/crates/pdfs-fuse/src/photos.rs +++ b/crates/pdfs-fuse/src/photos.rs @@ -8,6 +8,7 @@ use std::collections::HashMap; use std::path::PathBuf; +use pdfs_core::{CoreError, CoreResult}; use pdfs_core::control::{PhotoItem, PhotoKind, PhotoThumb}; use pdfs_core::db::{self, StoredPhoto}; use std::sync::atomic::Ordering; @@ -28,8 +29,8 @@ impl Core { limit: usize, kind: Option, range: Option<(i64, i64)>, - ) -> Result>, String> { - let count = self.db.photos_count().map_err(|e| e.to_string())?; + ) -> CoreResult>> { + let count = self.db.photos_count().map_err(CoreError::from)?; if count == 0 { // Nothing to serve, so this one request has to wait for the fetch — // unless we already know the account has no photos volume and the @@ -48,7 +49,7 @@ impl Core { let page = self .db .photos_page(offset, limit, kind, range) - .map_err(|e| e.to_string())?; + .map_err(CoreError::from)?; Ok(Some(page.into_iter().map(|p| self.photo_item(p)).collect())) } @@ -269,12 +270,12 @@ impl Core { /// Whether the listing stamped under `key` is older than `ttl` (or was never /// fetched). - pub(crate) async fn refresh_timeline(&self) -> Result { + pub(crate) async fn refresh_timeline(&self) -> CoreResult { let photos = self.photos(); if photos .get_photos_root() .await - .map_err(|e| format!("photos root: {e}"))? + .map_err(|e| CoreError::remote(format!("photos root: {e}")))? .is_none() { let _ = self.db.set_state_i64(PHOTOS_AVAILABLE, 0); @@ -284,7 +285,7 @@ impl Core { let items = photos .enumerate_timeline() .await - .map_err(|e| format!("timeline: {e}"))?; + .map_err(|e| CoreError::remote(format!("timeline: {e}")))?; // The timeline DTO carries only a uid and capture time, but the Photos // page has to split into Photos / Videos / Raw — which needs each photo's @@ -317,7 +318,7 @@ impl Core { (key, it.capture_time, name, media_type) }) .collect(); - self.db.photos_replace(&rows).map_err(|e| e.to_string())?; + self.db.photos_replace(&rows).map_err(CoreError::from)?; let _ = self.db.set_state_i64(PHOTOS_AVAILABLE, 1); let _ = self.db.set_state_i64(PHOTOS_SYNCED_MS, now_ms()); Ok(true) @@ -340,23 +341,23 @@ impl Core { /// Download a photo's full content into the content cache, returning its /// on-disk path (served from cache when a fresh blob already exists). - pub(crate) fn open_photo(&self, uid: &NodeUid) -> Result { + pub(crate) fn open_photo(&self, uid: &NodeUid) -> CoreResult { let photos = self.photos(); let node = self .rt .block_on(photos.get_node(uid)) - .map_err(|e| format!("photo node: {e}"))? - .ok_or("photo not found")?; + .map_err(|e| CoreError::remote(format!("photo node: {e}")))? + .ok_or_else(|| CoreError::not_found("photo not found"))?; let (mtime, size) = (node.modification_time, node_size(&node)); if let Some(p) = self.cache.cached_content_path(uid, mtime, size) { return Ok(p); } let bytes = self .download_photo_tracked(&photos, uid, &node.name, size) - .map_err(|e| format!("download photo: {e}"))?; + .map_err(|e| CoreError::remote(format!("download photo: {e}")))?; self.cache .store(uid, mtime, size, &bytes) - .map_err(|e| format!("cache store: {e}"))?; + .map_err(|e| CoreError::internal(format!("cache store: {e}")))?; Ok(self.cache.content_path(uid)) } diff --git a/crates/pdfs-fuse/src/sharing.rs b/crates/pdfs-fuse/src/sharing.rs index e638f1e..187f2f3 100644 --- a/crates/pdfs-fuse/src/sharing.rs +++ b/crates/pdfs-fuse/src/sharing.rs @@ -8,6 +8,7 @@ use std::path::Path; +use pdfs_core::{CoreError, CoreResult}; use pdfs_core::control::{ BookmarkInfo, DirEntry, InvitationInfo, PublicLinkInfo, ShareEntry, ShareEntryKind, SharedItem, }; @@ -27,29 +28,25 @@ impl Core { emails: &[String], role: &str, message: Option<&str>, - ) -> Result<(usize, usize), String> { - let (_ino, uid) = self - .resolve_path(rel) - .map_err(|e| format!("resolve path: {e:?}"))?; + ) -> CoreResult<(usize, usize)> { + let (_ino, uid) = self.resolve(rel)?; let role = role_from_str(role)?; let invitees: Vec<(String, MemberRole)> = emails.iter().map(|e| (e.clone(), role)).collect(); self.rt .block_on(self.client.invite_users(&uid, &invitees, message)) - .map_err(|e| format!("share: {e}")) + .map_err(|e| CoreError::remote(format!("share: {e}"))) } /// List the members, pending invitations and public link of the node at `rel`. - pub(crate) fn list_share(&self, rel: &Path) -> Result<(Vec, Option), String> { - let (_ino, uid) = self - .resolve_path(rel) - .map_err(|e| format!("resolve path: {e:?}"))?; + pub(crate) fn list_share(&self, rel: &Path) -> CoreResult<(Vec, Option)> { + let (_ino, uid) = self.resolve(rel)?; let mut entries = Vec::new(); for m in self .rt .block_on(self.client.list_share_members(&uid)) - .map_err(|e| format!("list members: {e}"))? + .map_err(|e| CoreError::remote(format!("list members: {e}")))? { entries.push(ShareEntry { id: m.membership_id.to_string(), @@ -61,7 +58,7 @@ impl Core { for inv in self .rt .block_on(self.client.list_share_invitations(&uid)) - .map_err(|e| format!("list invitations: {e}"))? + .map_err(|e| CoreError::remote(format!("list invitations: {e}")))? { entries.push(ShareEntry { id: inv.invitation_id, @@ -73,7 +70,7 @@ impl Core { for ext in self .rt .block_on(self.client.list_external_invitations(&uid)) - .map_err(|e| format!("list external invitations: {e}"))? + .map_err(|e| CoreError::remote(format!("list external invitations: {e}")))? { entries.push(ShareEntry { id: ext.invitation_id, @@ -86,7 +83,7 @@ impl Core { let link = self .rt .block_on(self.client.get_public_link(&uid)) - .map_err(|e| format!("get public link: {e}"))? + .map_err(|e| CoreError::remote(format!("get public link: {e}")))? .map(public_link_info); Ok((entries, link)) @@ -100,84 +97,80 @@ impl Core { id: &str, kind: ShareEntryKind, role: &str, - ) -> Result<(), String> { - let (_ino, uid) = self - .resolve_path(rel) - .map_err(|e| format!("resolve path: {e:?}"))?; + ) -> CoreResult<()> { + let (_ino, uid) = self.resolve(rel)?; let role = role_from_str(role)?; match kind { ShareEntryKind::Member => { let member = self .rt .block_on(self.client.list_share_members(&uid)) - .map_err(|e| format!("list members: {e}"))? + .map_err(|e| CoreError::remote(format!("list members: {e}")))? .into_iter() .find(|m| m.membership_id.to_string() == id) - .ok_or_else(|| "member not found".to_string())?; + .ok_or_else(|| CoreError::not_found("member not found"))?; self.rt .block_on(self.client.update_member_role(&member, role)) - .map_err(|e| format!("update role: {e}")) + .map_err(|e| CoreError::remote(format!("update role: {e}"))) } ShareEntryKind::ProtonInvite => { let inv = self .rt .block_on(self.client.list_share_invitations(&uid)) - .map_err(|e| format!("list invitations: {e}"))? + .map_err(|e| CoreError::remote(format!("list invitations: {e}")))? .into_iter() .find(|i| i.invitation_id == id) - .ok_or_else(|| "invitation not found".to_string())?; + .ok_or_else(|| CoreError::not_found("invitation not found"))?; self.rt .block_on(self.client.update_invitation_role(&inv, role)) - .map_err(|e| format!("update role: {e}")) + .map_err(|e| CoreError::remote(format!("update role: {e}"))) } ShareEntryKind::ExternalInvite => { - Err("an external invitation's role cannot be changed".to_string()) + Err(CoreError::invalid("an external invitation's role cannot be changed")) } } } /// Remove a member, pending Proton invite, or external invite from the node /// at `rel`. - pub(crate) fn remove_share_entry(&self, rel: &Path, id: &str, kind: ShareEntryKind) -> Result<(), String> { - let (_ino, uid) = self - .resolve_path(rel) - .map_err(|e| format!("resolve path: {e:?}"))?; + pub(crate) fn remove_share_entry(&self, rel: &Path, id: &str, kind: ShareEntryKind) -> CoreResult<()> { + let (_ino, uid) = self.resolve(rel)?; match kind { ShareEntryKind::Member => { let member = self .rt .block_on(self.client.list_share_members(&uid)) - .map_err(|e| format!("list members: {e}"))? + .map_err(|e| CoreError::remote(format!("list members: {e}")))? .into_iter() .find(|m| m.membership_id.to_string() == id) - .ok_or_else(|| "member not found".to_string())?; + .ok_or_else(|| CoreError::not_found("member not found"))?; self.rt .block_on(self.client.remove_member(&member)) - .map_err(|e| format!("remove member: {e}")) + .map_err(|e| CoreError::remote(format!("remove member: {e}"))) } ShareEntryKind::ProtonInvite => { let inv = self .rt .block_on(self.client.list_share_invitations(&uid)) - .map_err(|e| format!("list invitations: {e}"))? + .map_err(|e| CoreError::remote(format!("list invitations: {e}")))? .into_iter() .find(|i| i.invitation_id == id) - .ok_or_else(|| "invitation not found".to_string())?; + .ok_or_else(|| CoreError::not_found("invitation not found"))?; self.rt .block_on(self.client.delete_invitation(&inv)) - .map_err(|e| format!("revoke invitation: {e}")) + .map_err(|e| CoreError::remote(format!("revoke invitation: {e}"))) } ShareEntryKind::ExternalInvite => { let ext = self .rt .block_on(self.client.list_external_invitations(&uid)) - .map_err(|e| format!("list external invitations: {e}"))? + .map_err(|e| CoreError::remote(format!("list external invitations: {e}")))? .into_iter() .find(|i| i.invitation_id == id) - .ok_or_else(|| "external invitation not found".to_string())?; + .ok_or_else(|| CoreError::not_found("external invitation not found"))?; self.rt .block_on(self.client.delete_external_invitation(&ext)) - .map_err(|e| format!("revoke external invitation: {e}")) + .map_err(|e| CoreError::remote(format!("revoke external invitation: {e}"))) } } } @@ -189,10 +182,8 @@ impl Core { role: &str, password: Option<&str>, expires: Option, - ) -> Result { - let (_ino, uid) = self - .resolve_path(rel) - .map_err(|e| format!("resolve path: {e:?}"))?; + ) -> CoreResult { + let (_ino, uid) = self.resolve(rel)?; let role = role_from_str(role)?; let link = self .rt @@ -200,41 +191,39 @@ impl Core { self.client .create_public_link(&uid, role, password, expires), ) - .map_err(|e| format!("create public link: {e}"))?; + .map_err(|e| CoreError::remote(format!("create public link: {e}")))?; Ok(public_link_info(link)) } /// Remove the public link `id` from the node at `rel`. - pub(crate) fn remove_public_link(&self, rel: &Path, id: &str) -> Result<(), String> { - let (_ino, uid) = self - .resolve_path(rel) - .map_err(|e| format!("resolve path: {e:?}"))?; + pub(crate) fn remove_public_link(&self, rel: &Path, id: &str) -> CoreResult<()> { + let (_ino, uid) = self.resolve(rel)?; let link = self .rt .block_on(self.client.get_public_link(&uid)) - .map_err(|e| format!("get public link: {e}"))? + .map_err(|e| CoreError::remote(format!("get public link: {e}")))? .filter(|l| l.public_link_id == id) - .ok_or_else(|| "public link not found".to_string())?; + .ok_or_else(|| CoreError::not_found("public link not found"))?; self.rt .block_on(self.client.remove_public_link(&link)) - .map_err(|e| format!("remove public link: {e}")) + .map_err(|e| CoreError::remote(format!("remove public link: {e}"))) } // ---- shared with me --------------------------------------------------- /// List nodes shared with me that I have accepted. - pub(crate) fn list_shared_with_me(&self) -> Result, String> { + pub(crate) fn list_shared_with_me(&self) -> CoreResult> { let uids = self .rt .block_on(self.client.enumerate_shared_with_me_node_uids()) - .map_err(|e| format!("enumerate shared: {e}"))?; + .map_err(|e| CoreError::remote(format!("enumerate shared: {e}")))?; if uids.is_empty() { return Ok(Vec::new()); } let nodes = self .rt .block_on(self.client.enumerate_nodes(&uids)) - .map_err(|e| format!("enumerate nodes: {e}"))?; + .map_err(|e| CoreError::remote(format!("enumerate nodes: {e}")))?; Ok(nodes .into_iter() .map(|n| { @@ -262,21 +251,21 @@ impl Core { } /// Leave a shared node by its uid. - pub(crate) fn leave_shared(&self, uid: &str) -> Result<(), String> { - let uid = parse_uid(uid).ok_or_else(|| format!("invalid uid: {uid}"))?; + pub(crate) fn leave_shared(&self, uid: &str) -> CoreResult<()> { + let uid = parse_uid(uid).ok_or_else(|| CoreError::invalid(format!("invalid uid: {uid}")))?; self.rt .block_on(self.client.leave_shared_node(&uid)) - .map_err(|e| format!("leave shared: {e}")) + .map_err(|e| CoreError::remote(format!("leave shared: {e}"))) } // ---- incoming invitations --------------------------------------------- /// List invitations addressed to me, pending accept or reject. - pub(crate) fn list_invitations(&self) -> Result, String> { + pub(crate) fn list_invitations(&self) -> CoreResult> { let invitations = self .rt .block_on(self.client.list_incoming_invitations()) - .map_err(|e| format!("list invitations: {e}"))?; + .map_err(|e| CoreError::remote(format!("list invitations: {e}")))?; Ok(invitations .into_iter() .map(|i| InvitationInfo { @@ -289,27 +278,27 @@ impl Core { } /// Accept an invitation addressed to me by its id. - pub(crate) fn accept_invitation(&self, id: &str) -> Result<(), String> { + pub(crate) fn accept_invitation(&self, id: &str) -> CoreResult<()> { self.rt .block_on(self.client.accept_invitation(id)) - .map_err(|e| format!("accept invitation: {e}")) + .map_err(|e| CoreError::remote(format!("accept invitation: {e}"))) } /// Reject an invitation addressed to me by its id. - pub(crate) fn reject_invitation(&self, id: &str) -> Result<(), String> { + pub(crate) fn reject_invitation(&self, id: &str) -> CoreResult<()> { self.rt .block_on(self.client.reject_invitation(id)) - .map_err(|e| format!("reject invitation: {e}")) + .map_err(|e| CoreError::remote(format!("reject invitation: {e}"))) } // ---- bookmarks -------------------------------------------------------- /// List public links saved to my account. - pub(crate) fn list_bookmarks(&self) -> Result, String> { + pub(crate) fn list_bookmarks(&self) -> CoreResult> { let bookmarks = self .rt .block_on(self.client.list_bookmarks()) - .map_err(|e| format!("list bookmarks: {e}"))?; + .map_err(|e| CoreError::remote(format!("list bookmarks: {e}")))?; Ok(bookmarks .into_iter() .map(|b| BookmarkInfo { @@ -322,17 +311,17 @@ impl Core { } /// Save a public link (optionally password-protected) as a bookmark. - pub(crate) fn create_bookmark(&self, url: &str, password: Option<&str>) -> Result<(), String> { + pub(crate) fn create_bookmark(&self, url: &str, password: Option<&str>) -> CoreResult<()> { self.rt .block_on(self.client.create_bookmark(url, password)) - .map_err(|e| format!("create bookmark: {e}")) + .map_err(|e| CoreError::remote(format!("create bookmark: {e}"))) } /// Remove a saved bookmark by its token. - pub(crate) fn delete_bookmark(&self, token: &str) -> Result<(), String> { + pub(crate) fn delete_bookmark(&self, token: &str) -> CoreResult<()> { self.rt .block_on(self.client.delete_bookmark(token)) - .map_err(|e| format!("delete bookmark: {e}")) + .map_err(|e| CoreError::remote(format!("delete bookmark: {e}"))) } // ---- shared by me ----------------------------------------------------- @@ -342,18 +331,18 @@ impl Core { /// the shared uids; the per-node detail is then gathered best-effort — a single /// node racing with an unshare drops from the list rather than failing the whole /// request. - pub(crate) fn list_shared_by_me(&self) -> Result, String> { + pub(crate) fn list_shared_by_me(&self) -> CoreResult> { let uids = self .rt .block_on(self.client.enumerate_shared_by_me_node_uids()) - .map_err(|e| format!("enumerate shared-by-me: {e}"))?; + .map_err(|e| CoreError::remote(format!("enumerate shared-by-me: {e}")))?; if uids.is_empty() { return Ok(Vec::new()); } let nodes = self .rt .block_on(self.client.enumerate_nodes(&uids)) - .map_err(|e| format!("enumerate nodes: {e}"))?; + .map_err(|e| CoreError::remote(format!("enumerate nodes: {e}")))?; let mut items = Vec::with_capacity(nodes.len()); for n in nodes { let uid = n.uid.clone(); diff --git a/crates/pdfs-gui/Cargo.toml b/crates/pdfs-gui/Cargo.toml index 95d74a0..c19af54 100644 --- a/crates/pdfs-gui/Cargo.toml +++ b/crates/pdfs-gui/Cargo.toml @@ -12,7 +12,7 @@ path = "src/main.rs" [[bin]] name = "pdfs-app" -path = "src/app.rs" +path = "src/app/main.rs" [[bin]] name = "pdfs-prompt" diff --git a/crates/pdfs-gui/src/app.rs b/crates/pdfs-gui/src/app.rs deleted file mode 100644 index 4d40a69..0000000 --- a/crates/pdfs-gui/src/app.rs +++ /dev/null @@ -1,8511 +0,0 @@ -//! `pdfs-app` — GTK4 / libadwaita desktop window for the Proton Drive Linux -//! client. -//! -//! Slice 2 of the GUI phase: the settings/management window that complements the -//! [`pdfs-tray`](crate) SNI icon. It presents four things in a Proton-purple, -//! Google-Drive-style layout: -//! -//! * a **login form** (email / password, with a lazy 2FA dialog shown only when -//! the account requires it) driving [`pdfs_core::auth::login`]; logging in -//! enables the systemd mount service; -//! * a **read-only mount status** line (the mount is managed automatically); -//! * a **cache usage** read-out; and -//! * the **pin list** with per-file unpin. -//! -//! Mount status, cache usage and the pin list all ride along on one -//! [`Request::Status`] round-trip to the daemon (which owns the cache), fetched on -//! a worker thread so the periodic refresh never blocks the GTK main loop. Login -//! identity is cached in [`Ui`] and refreshed only on login/logout, so the 2s tick -//! never touches the keyring. - -use std::cell::{Cell, RefCell}; -use std::collections::{HashMap, HashSet, VecDeque}; -use std::path::{Path, PathBuf}; -use std::process::Command; -use std::rc::Rc; -use std::time::{Duration, Instant}; - -use adw::prelude::*; -use gtk4::gio; -use gtk4::glib; -use gtk4::glib::BoxedAnyObject; - -use pdfs_core::auth; -use pdfs_core::config::AppDirs; -use pdfs_core::control::{ - ActivityEntry, ActivityKind, BookmarkInfo, DeviceInfo, DirEntry, InvitationInfo, JobItem, - PhotoItem, PhotoKind, PublicLinkInfo, RefreshScope, Request, Response, SearchHit, ShareEntry, - ShareEntryKind, SharedItem, SyncFolderInfo, SyncPhase, SyncProgress, TransferDirection, - TransferItem, pending_summary, send, -}; -use pdfs_core::service; - -/// How many photos to pull per [`Request::PhotosTimeline`] page. -const PHOTOS_PAGE: usize = 60; - -const APP_ID: &str = "io.narl.proton-drive-linux"; -/// Proton brand purple, applied as the libadwaita accent so switches, buttons, -/// links and the storage bar all pick it up. -const PROTON_PURPLE: &str = "#6d4aff"; -/// How often the window re-reads mount status, cache usage and the pin list. -const REFRESH_INTERVAL: Duration = Duration::from_secs(2); -/// Backoff between auto-retries of a Files/Photos load while the mount service -/// is still coming up (see [`load_browser`] / [`load_gallery`]). -const CONNECT_RETRY_INTERVAL: Duration = Duration::from_secs(2); - -/// How long a network-backed page (Shared, Shared-by-me, Devices, Activity) is -/// considered fresh. Re-navigating to it within this window reuses the rows -/// already on screen instead of re-fetching and flashing the "Loading…" -/// placeholder. The Retry button and every mutation still force an immediate -/// reload by clearing the page's timestamp. -const PAGE_TTL: Duration = Duration::from_secs(30); -/// Idle pause after the last keystroke before a search query is sent, so typing -/// doesn't fire a request per character. -const SEARCH_DEBOUNCE: Duration = Duration::from_millis(250); -/// Cap on search hits requested from the daemon. -const SEARCH_LIMIT: usize = 200; - -/// Gallery row height in px: the zoom range, its default, and the step one -/// Ctrl+scroll notch (or Ctrl+±) moves it by. Rows are justified to the full -/// content width, so this is the *target* height a row lands near, not an exact -/// tile size (see [`justify_rows`]). -const TILE_MIN: i32 = 90; -const TILE_MAX: i32 = 340; -const TILE_DEFAULT: i32 = 220; -const TILE_STEP: i32 = 30; -/// Gap between tiles, horizontally and vertically. -const TILE_GAP: i32 = 6; -/// Aspect ratio (w/h) assumed for a photo whose thumbnail hasn't been decoded -/// yet, so a tile can be laid out before its image exists. Landscape 3:2, the -/// commonest camera/phone ratio; once the thumbnail lands the real ratio -/// replaces it and the section re-justifies in place. -const RATIO_UNKNOWN: f64 = 1.5; -/// Aspect ratios are clamped to this range before a row is packed: one absurd -/// panorama, or a sliver of a portrait, must not be able to squash the row it -/// lands in down to nothing. The tile still shows the whole image. -const RATIO_MIN: f64 = 0.4; -const RATIO_MAX: f64 = 3.5; -/// How many thumbnails one on-demand [`Request::PhotoThumbs`] batch asks for. -/// Small, so the first tiles on screen fill in quickly rather than the whole -/// page landing at once. -const THUMB_BATCH: usize = 16; -/// Idle pause before a thumbnail batch is sent, so a fast scroll coalesces into -/// one request per settle instead of one per row that flickers past. -const THUMB_DEBOUNCE: Duration = Duration::from_millis(60); -/// How long to wait before asking again for a thumbnail the daemon is generating -/// itself. That means downloading the photo's full file, so the wait is measured -/// in seconds, not milliseconds. -const THUMB_RETRY: Duration = Duration::from_secs(4); -/// Decoded thumbnails held in memory. Each is a few hundred KiB of GPU texture; -/// this caps the gallery's footprint while covering several screens of scroll. -const TEXTURE_CACHE_MAX: usize = 600; -/// Pause after a resize/zoom before the visible sections are re-justified. -const RELAYOUT_DEBOUNCE: Duration = Duration::from_millis(80); - -/// One rendered pin row, retained so [`repaint_pins`] can flip the unpin button's -/// `sensitive` in place (when the pin set is unchanged) instead of rebuilding. -struct PinRow { - row: adw::ActionRow, - /// The unpin button, absent on the placeholder row. - unpin: Option, -} - -/// One rendered row in the Activity group: a description over a progress bar. -/// Retained so [`repaint_transfers`] can update the bar and label in place each -/// tick when the active set is unchanged, instead of rebuilding. -struct TransferRow { - row: adw::PreferencesRow, - label: gtk4::Label, - bar: gtk4::ProgressBar, -} - -/// What one Activity row should say this tick, and how far along it is — -/// `None` meaning "no total known", which the bar shows by pulsing. Jobs and -/// transfers both render to this, so the group is one list in the order the -/// daemon reports: the jobs that frame the work, then the files moving under it. -struct ActivityLine { - text: String, - fraction: Option, -} - -/// All widgets the periodic refresh and the action handlers mutate, plus the -/// resolved paths they act on. Wrapped in an [`Rc`] so handlers and the timeout -/// closure share one instance. -struct Ui { - dirs: AppDirs, - stack: adw::ViewStack, - /// Wraps the whole window content; every non-blocking outcome (a completed - /// rename, a failed upload, a purge) is reported here rather than in a modal, - /// so an action never interrupts what the user is doing next. - toasts: adw::ToastOverlay, - /// Header spinner shown while any open/load round-trip is in flight; ref- - /// counted via [`Self::busy`] so concurrent operations don't stop it early. - spinner: gtk4::Spinner, - busy: Cell, - /// Keys (relative path / photo uid) of open requests currently in flight, so - /// a double-click on the same entry is a no-op instead of a second download. - opening: RefCell>, - /// Resolved login identity, cached so the periodic [`refresh`] never hits the - /// keyring (a DBus round-trip). Populated at startup and updated only on - /// login / logout. `None` = signed out. - session: RefCell>, - /// Whether a [`Request::Status`] round-trip is already in flight, so the 2s - /// refresh tick doesn't pile worker threads up on a slow/wedged daemon. - status_inflight: Cell, - /// Guards the [`Request::GetQueueStatus`] poll the same way: at most one - /// in-flight at a time so a wedged daemon can't stack worker threads. - transfers_inflight: Cell, - /// Activity group + its current rows, hidden when no transfer is in flight. - transfers_group: adw::PreferencesGroup, - transfer_rows: RefCell>, - // Login page. - email: adw::EntryRow, - password: adw::PasswordEntryRow, - login_button: gtk4::Button, - login_status: gtk4::Label, - // Main page. - account_row: adw::ActionRow, - /// Read-only mount status line. The mount is driven by the systemd user - /// service (enabled on login), not by the user — so this only reports. - 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. - pin_rows: RefCell>, - /// The pin paths last rendered, the diff baseline for [`repaint_pins`]. - /// `None` = nothing built yet; `Some(empty)` = the placeholder is shown. - pins_state: RefCell>>, - /// Whether the last refresh saw a live mount daemon. Gates the unpin buttons - /// (which need the daemon to evict + re-hydrate) and every mutating action. - mounted: RefCell, - /// The mount state the *last* desktop notification reported, so a flap only - /// notifies on the edge. `None` until the first status reply, so a cold start - /// doesn't announce "disconnected" before the service has had a chance to come - /// up. - notified_mounted: Cell>, - /// How many transfers were in flight on the previous poll. A drop to zero is - /// what "sync complete" means; there's no completion event on the wire. - active_transfers: Cell, - /// Sidebar destination list (Files / Photos / Settings). Selecting a row swaps - /// the page stack; [`sync_sidebar`] mirrors navigation that starts elsewhere. - sidebar: gtk4::ListBox, - /// The sidebar/content split. Collapsed while signed out, so the login page - /// owns the whole window and no destination is reachable without a session. - nav: adw::NavigationSplitView, - // Files (browser) page. - /// Shared model behind the grid and column views; repopulated per directory. - browser_model: gio::ListStore, - browser_back: gtk4::Button, - /// 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, - /// Swaps the Files content area between the grid/list views and the status - /// page below; see [`browser_status`]. - browser_content: gtk4::Stack, - /// The Files empty/loading/error surface, shown in place of the views. - browser_status: adw::StatusPage, - /// Sits in [`Self::browser_status`]; shown when a load failed because the mount - /// service is down (not merely starting); restarts the service and reloads. - browser_retry: gtk4::Button, - /// The details pane's host; `show_sidebar` is what reveals/hides the pane. - browser_split: adw::OverlaySplitView, - /// Widgets in the details pane, repainted from the selected entry. - details: DetailsWidgets, - /// The entry the details pane is currently showing, so its buttons act on the - /// same one the user is looking at even after the model is repopulated. - details_entry: RefCell>, - /// Set while the details pane is being populated, so setting the offline - /// switch programmatically doesn't fire a pin/unpin round-trip. - details_suppress: Cell, - /// The grid's and list's selection models. Both wrap the one browser model, so - /// a selection in either drives the details pane. - grid_selection: gtk4::SingleSelection, - list_selection: gtk4::SingleSelection, - /// Mountpoint-relative path the browser is showing (empty = root). - browser_path: RefCell, - /// Debounced full-text search box in the browser header. - browser_search: gtk4::SearchEntry, - /// The folder-level actions, insensitive while the mount is down: without a - /// daemon they can only fail, and a greyed button says so before the click. - browser_new_folder: gtk4::Button, - browser_upload: gtk4::Button, - browser_upload_folder: gtk4::Button, - /// Pending debounce timer for the search box; replaced on every keystroke so - /// only the last pause actually fires a [`Request::Search`]. - search_source: RefCell>, - // Trash page. Trashed nodes are addressed by uid, not by path, so this page - // keeps no current-directory state — it re-lists from the daemon on show. - trash_model: gio::ListStore, - trash_content: gtk4::Stack, - trash_status: adw::StatusPage, - trash_retry: gtk4::Button, - /// Empties the trash; insensitive while it is empty (or unread). - trash_empty: gtk4::Button, - /// "12 items" under the page title. - trash_subtitle: gtk4::Label, - // Photos (gallery) page. - /// Every photo loaded so far, newest first — the order the lightbox's - /// prev/next walks. The visible day sections are derived from this. - gallery_model: gio::ListStore, - /// The day sections rendered by the Photos ListView, rebuilt from - /// [`Self::gallery_model`] by [`repaint_gallery`]. - gallery_groups: gio::ListStore, - /// Target row height in px, retuned by Ctrl+scroll / Ctrl+± (see - /// [`zoom_gallery`]). Each tile keeps its own aspect ratio; rows are - /// justified to the content width around this height. - gallery_tile: Cell, - /// Swaps the Photos content area between the timeline and its status page. - gallery_content: gtk4::Stack, - gallery_status: adw::StatusPage, - gallery_retry: gtk4::Button, - gallery_more: gtk4::Button, - gallery_upload: gtk4::Button, - /// "1,204 photos" under the page title. - gallery_subtitle: gtk4::Label, - /// Which tab (Photos / Videos / Raw) the timeline is filtered to, or `None` - /// for All. Read by [`load_gallery`] and set by the filter toggles. - gallery_kind: Cell>, - /// The filter toggles, index-aligned with [`kind_for_tab`], kept so their - /// labels can carry live per-kind counts. - gallery_tabs: [gtk4::ToggleButton; 4], - /// The date-jump dropdown ("All dates" then a month per timeline entry), and - /// the `[from, to)` window each of its rows selects (index-aligned; `None` is - /// "All dates"). Selecting a row loads that month via [`load_gallery`]. - gallery_dates: gtk4::DropDown, - gallery_date_ranges: RefCell>>, - /// The capture-time window the timeline is currently filtered to, or `None` - /// for the whole span. Read by [`load_gallery`], set by the date dropdown. - gallery_range: Cell>, - /// Set while the date dropdown is being repopulated, so resetting its model - /// doesn't fire the selection handler and kick off a spurious reload. - gallery_date_suppress: Cell, - /// True while a timeline page is in flight, so the scroll-to-the-end paging - /// can't fire a second request for the page already coming. - gallery_loading: Cell, - /// Content width the sections are currently justified to. Updated when the - /// ListView is resized, which re-justifies the visible sections. - gallery_width: Cell, - /// Decoded thumbnails by photo uid, with the insertion order that evicts the - /// oldest past [`TEXTURE_CACHE_MAX`]. Scrolling back over a day therefore - /// repaints from memory instead of re-decoding from disk. - photo_tex: RefCell>, - photo_tex_order: RefCell>, - /// Aspect ratio (w/h) per uid, learned when a thumbnail is decoded and - /// persisted to disk, so a relaunch justifies its rows correctly on the first - /// frame instead of reflowing as thumbnails arrive. - photo_ratio: RefCell>, - /// Ratios learned since the last save, so the ratio file is only rewritten - /// when it actually changed. - photo_ratio_dirty: Cell, - /// Photos the daemon reported as having no thumbnail at all, so a tile that - /// can never be filled isn't requested again on every scroll past it. - photo_nothumb: RefCell>, - /// Tiles on screen still waiting for their thumbnail, by uid. Populated as - /// sections are bound, drained as batches land, cleared on unbind — so a - /// batch only ever paints a widget that is still showing that photo. - thumb_wanted: RefCell>, - /// Uids queued for the next [`Request::PhotoThumbs`] batch, and whether a - /// batch is already in flight (only one at a time, so a long scroll can't - /// stack requests on the daemon). - thumb_queue: RefCell>, - thumb_inflight: Cell, - /// Thumbnails on disk waiting to be turned into textures, as `(uid, path)`. - /// Decoding happens on the GTK thread (textures are not `Send`), so it is fed - /// a few at a time from an idle callback rather than in one blocking burst - /// that would stutter the scroll. [`Self::decode_idle`] is the "callback - /// already scheduled" guard. - decode_queue: RefCell>, - decode_idle: Cell, - /// Pending debounce timers for the thumbnail queue flush and the section - /// re-justify, replaced on each new trigger so only the last one fires. - thumb_source: RefCell>, - relayout_source: RefCell>, - /// The day sections currently realised by the ListView, by their index in - /// [`Self::gallery_groups`]. A learned ratio or a resize re-justifies these - /// in place — rebuilding the ListStore instead would reset the scroll - /// position out from under the user. - gallery_bound: RefCell>, - // Shared page. Three sections rebuilt wholesale on each load: shared-with-me, - // incoming invitations, and bookmarks. Rows are tracked so a reload can remove - // the old ones before appending the new (adw groups have no clear-all). - shared_content: gtk4::Stack, - shared_status: adw::StatusPage, - shared_retry: gtk4::Button, - shared_with_me_group: adw::PreferencesGroup, - invitations_group: adw::PreferencesGroup, - bookmarks_group: adw::PreferencesGroup, - shared_rows: RefCell>, - /// Guards the Shared page's load so overlapping navigations don't stack. - shared_inflight: Cell, - /// When the Shared page last painted good data. `None` = never / invalidated, - /// forcing a fetch on next visit. See [`PAGE_TTL`]. - shared_loaded_at: Cell>, - // Devices page: two sections (synced folders + other devices), rebuilt - // wholesale on each load. - devices_content: gtk4::Stack, - devices_status: adw::StatusPage, - devices_retry: gtk4::Button, - devices_group: adw::PreferencesGroup, - devices_rows: RefCell>, - devices_sync_group: adw::PreferencesGroup, - devices_sync_rows: RefCell>, - devices_inflight: Cell, - devices_loaded_at: Cell>, - // Shared (by me) page: one section listing the items I've shared, each with a - // copy-link / manage affordance. Rebuilt wholesale on each load. - shared_by_me_content: gtk4::Stack, - shared_by_me_status: adw::StatusPage, - shared_by_me_retry: gtk4::Button, - shared_by_me_group: adw::PreferencesGroup, - shared_by_me_rows: RefCell>, - shared_by_me_inflight: Cell, - shared_by_me_loaded_at: Cell>, - // Activity page: one section, newest-first, rebuilt wholesale on each load. - activity_content: gtk4::Stack, - activity_status: adw::StatusPage, - activity_retry: gtk4::Button, - activity_group: adw::PreferencesGroup, - activity_rows: RefCell>, - activity_inflight: Cell, - /// Fingerprint of the feed currently on screen (see [`activity_key`]). The - /// page polls every couple of seconds and usually gets back exactly what it - /// is already showing; rebuilding all 200 rows for that would throw away the - /// user's scroll position several times a minute. - activity_key: RefCell>, -} - -impl Ui { - /// Begin a unit of background work: show + spin the header spinner. - fn busy_begin(&self) { - self.busy.set(self.busy.get() + 1); - self.spinner.set_visible(true); - self.spinner.start(); - } - - /// End a unit of background work: stop the spinner once the last one is done. - fn busy_end(&self) { - let remaining = self.busy.get().saturating_sub(1); - self.busy.set(remaining); - if remaining == 0 { - self.spinner.stop(); - self.spinner.set_visible(false); - } - } - - /// The aspect ratio (w/h) to lay `uid`'s tile out at: the real one once its - /// thumbnail has been seen, otherwise [`RATIO_UNKNOWN`]. - fn ratio(&self, uid: &str) -> f64 { - self.photo_ratio - .borrow() - .get(uid) - .copied() - .unwrap_or(RATIO_UNKNOWN) - } - - /// Remember a decoded thumbnail and the aspect ratio it revealed. Returns - /// true when the ratio is *new information* — the caller re-justifies the - /// affected rows, since they were laid out against a guess. - fn store_texture(&self, uid: &str, texture: gtk4::gdk::Texture) -> bool { - let (w, h) = (texture.width(), texture.height()); - - let mut cache = self.photo_tex.borrow_mut(); - let mut order = self.photo_tex_order.borrow_mut(); - if cache.insert(uid.to_string(), texture).is_none() { - order.push_back(uid.to_string()); - } - while order.len() > TEXTURE_CACHE_MAX { - if let Some(old) = order.pop_front() { - cache.remove(&old); - } - } - drop((cache, order)); - - if h <= 0 { - return false; - } - let ratio = f64::from(w) / f64::from(h); - let mut ratios = self.photo_ratio.borrow_mut(); - match ratios.insert(uid.to_string(), ratio) { - // Only a *changed* ratio invalidates a layout; re-decoding a photo we - // already sized correctly must not trigger another pass. - Some(prev) if (prev - ratio).abs() < f64::EPSILON => false, - _ => { - self.photo_ratio_dirty.set(true); - true - } - } - } - - /// Where the learned aspect ratios are persisted between runs. - fn ratio_path(&self) -> PathBuf { - self.dirs.cache_dir().join("photo-ratios.json") - } - - /// Load the persisted aspect ratios. A missing or corrupt file just means the - /// first paint uses [`RATIO_UNKNOWN`] and re-justifies as thumbnails land. - fn load_ratios(&self) { - let path = self.ratio_path(); - let Ok(text) = std::fs::read_to_string(&path) else { - return; - }; - match serde_json::from_str::>(&text) { - Ok(map) => *self.photo_ratio.borrow_mut() = map, - Err(e) => tracing::debug!("ignoring {}: {e}", path.display()), - } - } - - /// Persist the learned aspect ratios, if any were learned since the last save. - fn save_ratios(&self) { - if !self.photo_ratio_dirty.replace(false) { - return; - } - let path = self.ratio_path(); - let ratios = self.photo_ratio.borrow(); - let write = serde_json::to_vec(&*ratios) - .map_err(std::io::Error::other) - .and_then(|bytes| { - std::fs::create_dir_all(path.parent().unwrap_or(Path::new("/")))?; - std::fs::write(&path, bytes) - }); - if let Err(e) = write { - tracing::debug!("cannot save {}: {e}", path.display()); - } - } -} - -fn main() -> glib::ExitCode { - tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), - ) - .init(); - - let app = adw::Application::builder().application_id(APP_ID).build(); - app.connect_startup(|_| { - load_proton_theme(); - // Refresh the file manager's right-click pin/unpin scripts, so they always - // match the installed `pdfs`. - pdfs_core::shell::install_file_manager_scripts(); - spawn_tray(); - }); - app.connect_activate(build_window); - app.run() -} - -/// Spawn the tray icon process in the background. -fn spawn_tray() { - match Command::new("pdfs-tray").spawn() { - Ok(_) => tracing::info!("spawned `pdfs-tray`"), - Err(e) => tracing::error!("failed to spawn `pdfs-tray`: {e}"), - } -} - -/// Install a CSS provider that overrides libadwaita's accent colour with Proton -/// purple, app-wide. Named-colour overrides recolour the stock widgets (switch, -/// buttons, progress fill) without per-widget styling. -fn load_proton_theme() { - // Compile-in and register our custom GResources (e.g. custom icons) - let bytes = include_bytes!(concat!(env!("OUT_DIR"), "/pdfs.gresource")); - let resource_data = glib::Bytes::from_static(bytes); - if let Ok(resource) = gio::Resource::from_data(&resource_data) { - gio::resources_register(&resource); - } else { - tracing::error!("failed to load gresource bundle"); - } - - let css = format!( - "@define-color accent_bg_color {PROTON_PURPLE};\n\ - @define-color accent_color {PROTON_PURPLE};\n\ - .brand-title {{ font-size: 1.6rem; font-weight: 800; }}\n\ - .brand-icon {{ color: {PROTON_PURPLE}; }}\n\ - .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: #111014; }}\n\ - .viewer-top-bar {{ background: linear-gradient(to bottom, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0)); padding: 10px 16px 28px 20px; color: white; }}\n\ - .viewer-title {{ font-weight: 700; font-size: 1.05rem; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); color: white; }}\n\ - .viewer-counter {{ font-size: 0.8rem; color: rgba(255, 255, 255, 0.72); text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); }}\n\ - .viewer-action-btn {{ color: white; background-color: rgba(255, 255, 255, 0.12); border-radius: 50%; min-width: 34px; min-height: 34px; margin-left: 4px; }}\n\ - .viewer-action-btn:hover {{ background-color: rgba(255, 255, 255, 0.28); color: white; }}\n\ - .viewer-action-btn:checked {{ background-color: {PROTON_PURPLE}; color: white; }}\n\ - .viewer-close-btn {{ background-color: rgba(255, 255, 255, 0.2); margin-left: 10px; }}\n\ - .viewer-close-btn:hover {{ background-color: #e01b24; color: white; }}\n\ - .viewer-nav-btn {{ background-color: rgba(0, 0, 0, 0.45); color: white; margin: 20px; min-width: 44px; min-height: 44px; border-radius: 50%; opacity: 0.7; transition: opacity 150ms ease, background-color 150ms ease; }}\n\ - .viewer-nav-btn:hover {{ background-color: rgba(0, 0, 0, 0.85); color: white; opacity: 1; }}\n\ - .viewer-nav-btn:disabled {{ opacity: 0; }}\n\ - .viewer-spinner {{ color: white; }}\n\ - .viewer-status {{ color: white; font-size: 1rem; background-color: rgba(0, 0, 0, 0.75); padding: 12px 24px; border-radius: 12px; }}\n\ - .viewer-info-panel {{ background-color: @window_bg_color; border-left: 1px solid alpha(currentColor, 0.12); }}\n\ - .gallery-day {{ font-size: 1.05rem; font-weight: 700; padding: 2px 2px 6px 2px; }}\n\ - .photo-tile {{ padding: 0; margin: 0; min-height: 0; min-width: 0; border-radius: 10px; background: alpha(currentColor, 0.06); box-shadow: none; transition: filter 160ms ease, box-shadow 160ms ease; }}\n\ - .photo-tile:hover {{ filter: brightness(1.06); box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35); }}\n\ - .photo-tile:focus {{ outline: 2px solid {PROTON_PURPLE}; outline-offset: -2px; }}\n\ - .photo-placeholder {{ color: alpha(currentColor, 0.35); background: alpha(currentColor, 0.07); }}\n\ - .photo-caption {{ font-size: 0.78rem; color: white; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); padding: 22px 10px 6px 10px; opacity: 0; transition: opacity 160ms ease; }}\n\ - .photo-video-badge {{ color: white; background: rgba(0, 0, 0, 0.45); border-radius: 999px; padding: 8px; min-width: 20px; min-height: 20px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); transition: background 160ms ease; }}\n\ - .photo-tile:hover .photo-video-badge {{ background: alpha({PROTON_PURPLE}, 0.85); }}\n\ - .photo-tile:hover .photo-caption {{ opacity: 1; background: linear-gradient(to top, rgba(0, 0, 0, 0.55), rgba(0, 0, 0, 0)); }}\n\ - .card {{ border-radius: 8px; transition: transform 0.2s ease, filter 0.2s ease; margin: 4px; }}\n\ - .card:hover {{ transform: scale(1.02); filter: brightness(0.9); }}\n\ - .navigation-sidebar row {{ border-radius: 8px; margin: 2px 6px; }}\n\ - .navigation-sidebar row:selected {{ background: alpha({PROTON_PURPLE}, 0.16); color: {PROTON_PURPLE}; font-weight: 600; }}\n\ - .navigation-sidebar row:selected image {{ color: {PROTON_PURPLE}; }}\n\ - .file-tile:selected, .file-tile:hover:selected {{ background: alpha({PROTON_PURPLE}, 0.20); }}\n" - ); - let provider = gtk4::CssProvider::new(); - provider.load_from_string(&css); - if let Some(display) = gtk4::gdk::Display::default() { - gtk4::style_context_add_provider_for_display( - &display, - &provider, - gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, - ); - - // Register custom icons directory from our GResource with the icon theme - let icon_theme = gtk4::IconTheme::for_display(&display); - icon_theme.add_resource_path("/de/nils/protondrivelinux/icons"); - } -} - -/// Build the application window, wire the two pages, kick off the refresh loop, -/// and present it. -fn build_window(app: &adw::Application) { - let dirs = match AppDirs::new() { - Ok(d) => d, - Err(e) => { - tracing::error!("cannot resolve app dirs: {e}"); - return; - } - }; - - let stack = adw::ViewStack::new(); - let (login_page, login_widgets) = build_login_page(); - let (main_page, main_widgets) = build_main_page(); - let (browser_page, browser_widgets) = build_browser_page(); - let (gallery_page, gallery_widgets) = build_gallery_page(); - let (shared_page, shared_widgets) = build_shared_page(); - let (shared_by_me_page, shared_by_me_widgets) = build_shared_by_me_page(); - let (devices_page, devices_widgets) = build_devices_page(); - let (activity_page, activity_widgets) = build_activity_page(); - let (trash_page, trash_widgets) = build_trash_page(); - stack.add_named(&login_page, Some("login")); - stack.add_named(&main_page, Some("main")); - stack.add_named(&browser_page, Some("browser")); - stack.add_named(&gallery_page, Some("gallery")); - stack.add_named(&shared_by_me_page, Some("sharedbyme")); - stack.add_named(&shared_page, Some("shared")); - stack.add_named(&devices_page, Some("devices")); - stack.add_named(&activity_page, Some("activity")); - stack.add_named(&trash_page, Some("trash")); - - // Sidebar: the signed-in destinations. Selecting a row swaps the page stack; - // `sync_sidebar` pushes the other way when navigation happens elsewhere (e.g. - // login lands on Files). - let (sidebar_page, sidebar_list) = build_sidebar(); - - // Header spinner, hidden until a background open/load is in flight. - let spinner = gtk4::Spinner::new(); - spinner.set_visible(false); - let header = adw::HeaderBar::new(); - header.pack_end(&build_primary_menu()); - header.pack_end(&spinner); - let toolbar = adw::ToolbarView::new(); - toolbar.add_top_bar(&header); - toolbar.set_content(Some(&stack)); - - let content_page = adw::NavigationPage::builder() - .title("Proton Drive") - .child(&toolbar) - .build(); - let split = adw::NavigationSplitView::builder() - .sidebar(&sidebar_page) - .content(&content_page) - .min_sidebar_width(200.0) - .max_sidebar_width(240.0) - .build(); - - // Toasts float over everything, so a report from a background action reaches - // the user whichever page they're on. - let toasts = adw::ToastOverlay::new(); - toasts.set_child(Some(&split)); - - let ui = Rc::new(Ui { - dirs, - stack: stack.clone(), - toasts: toasts.clone(), - spinner: spinner.clone(), - busy: Cell::new(0), - opening: RefCell::new(HashSet::new()), - session: RefCell::new(auth::load().ok()), - status_inflight: Cell::new(false), - email: login_widgets.0, - password: login_widgets.1, - login_button: login_widgets.2, - login_status: login_widgets.3, - account_row: main_widgets.account_row.clone(), - mount_row: main_widgets.mount_row.clone(), - transfers_group: main_widgets.transfers_group.clone(), - transfer_rows: RefCell::new(Vec::new()), - transfers_inflight: Cell::new(false), - 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), - notified_mounted: Cell::new(None), - active_transfers: Cell::new(0), - sidebar: sidebar_list.clone(), - nav: split.clone(), - browser_model: browser_widgets.model.clone(), - browser_back: browser_widgets.back.clone(), - browser_crumb: browser_widgets.crumb.clone(), - browser_content: browser_widgets.content.clone(), - browser_status: browser_widgets.status.clone(), - browser_retry: browser_widgets.retry.clone(), - browser_split: browser_widgets.split.clone(), - details: browser_widgets.details, - details_entry: RefCell::new(None), - details_suppress: Cell::new(false), - grid_selection: browser_widgets.grid_selection.clone(), - list_selection: browser_widgets.list_selection.clone(), - browser_path: RefCell::new(String::new()), - browser_search: browser_widgets.search.clone(), - browser_new_folder: browser_widgets.new_folder.clone(), - browser_upload: browser_widgets.upload.clone(), - browser_upload_folder: browser_widgets.upload_folder.clone(), - search_source: RefCell::new(None), - trash_model: trash_widgets.model.clone(), - trash_content: trash_widgets.content.clone(), - trash_status: trash_widgets.status.clone(), - trash_retry: trash_widgets.retry.clone(), - trash_empty: trash_widgets.empty.clone(), - trash_subtitle: trash_widgets.subtitle.clone(), - gallery_model: gallery_widgets.model.clone(), - gallery_groups: gallery_widgets.groups.clone(), - gallery_tile: Cell::new(TILE_DEFAULT), - gallery_content: gallery_widgets.content.clone(), - gallery_status: gallery_widgets.status.clone(), - gallery_retry: gallery_widgets.retry.clone(), - gallery_more: gallery_widgets.more.clone(), - gallery_upload: gallery_widgets.upload.clone(), - gallery_subtitle: gallery_widgets.subtitle.clone(), - gallery_kind: Cell::new(None), - gallery_tabs: gallery_widgets.tabs.clone(), - gallery_dates: gallery_widgets.dates.clone(), - gallery_date_ranges: RefCell::new(vec![None]), - gallery_range: Cell::new(None), - gallery_date_suppress: Cell::new(false), - gallery_loading: Cell::new(false), - gallery_width: Cell::new(0), - photo_tex: RefCell::new(HashMap::new()), - photo_tex_order: RefCell::new(VecDeque::new()), - photo_ratio: RefCell::new(HashMap::new()), - photo_ratio_dirty: Cell::new(false), - photo_nothumb: RefCell::new(HashSet::new()), - thumb_wanted: RefCell::new(HashMap::new()), - thumb_queue: RefCell::new(VecDeque::new()), - thumb_inflight: Cell::new(false), - decode_queue: RefCell::new(VecDeque::new()), - decode_idle: Cell::new(false), - thumb_source: RefCell::new(None), - relayout_source: RefCell::new(None), - gallery_bound: RefCell::new(HashMap::new()), - shared_content: shared_widgets.content.clone(), - shared_status: shared_widgets.status.clone(), - shared_retry: shared_widgets.retry.clone(), - shared_with_me_group: shared_widgets.shared_with_me.clone(), - invitations_group: shared_widgets.invitations.clone(), - bookmarks_group: shared_widgets.bookmarks.clone(), - shared_rows: RefCell::new(Vec::new()), - shared_inflight: Cell::new(false), - shared_loaded_at: Cell::new(None), - devices_content: devices_widgets.content.clone(), - devices_status: devices_widgets.status.clone(), - devices_retry: devices_widgets.retry.clone(), - devices_group: devices_widgets.group.clone(), - devices_rows: RefCell::new(Vec::new()), - devices_sync_group: devices_widgets.sync_group.clone(), - devices_sync_rows: RefCell::new(Vec::new()), - devices_inflight: Cell::new(false), - devices_loaded_at: Cell::new(None), - shared_by_me_content: shared_by_me_widgets.content.clone(), - shared_by_me_status: shared_by_me_widgets.status.clone(), - shared_by_me_retry: shared_by_me_widgets.retry.clone(), - shared_by_me_group: shared_by_me_widgets.group.clone(), - shared_by_me_rows: RefCell::new(Vec::new()), - shared_by_me_inflight: Cell::new(false), - shared_by_me_loaded_at: Cell::new(None), - activity_content: activity_widgets.content.clone(), - activity_status: activity_widgets.status.clone(), - activity_retry: activity_widgets.retry.clone(), - activity_group: activity_widgets.group.clone(), - activity_rows: RefCell::new(Vec::new()), - activity_inflight: Cell::new(false), - activity_key: RefCell::new(None), - }); - ui.load_ratios(); - - wire_login(&ui); - wire_logout(&ui, &main_widgets.logout_button); - wire_settings( - &ui, - &main_widgets.purge_button, - &main_widgets.mountpoint_button, - ); - wire_sidebar(&ui); - wire_browser(&ui, &browser_widgets.grid, &browser_widgets.column_view); - wire_browser_actions( - &ui, - &browser_widgets.new_folder, - &browser_widgets.upload, - &browser_widgets.upload_folder, - ); - wire_details(&ui); - wire_search(&ui); - wire_gallery(&ui, &gallery_widgets.list, &gallery_widgets.scroll); - wire_trash(&ui, &trash_widgets.list, &trash_widgets.empty); - wire_shared(&ui, &shared_widgets.retry, &shared_widgets.add_bookmark); - wire_shared_by_me(&ui, &shared_by_me_widgets.retry); - wire_devices(&ui, &devices_widgets.retry, &devices_widgets.add_folder); - wire_activity(&ui, &activity_widgets.retry); - wire_refresh( - &ui, - &[ - &browser_widgets.refresh, - &gallery_widgets.refresh, - &trash_widgets.refresh, - &shared_widgets.refresh, - &shared_by_me_widgets.refresh, - &devices_widgets.refresh, - &activity_widgets.refresh, - ], - ); - wire_retry(&ui); - - // Lazily load the Files / Photos pages the first time they're shown, so the - // network round-trip only happens on demand rather than on every refresh. - let ui_nav = ui.clone(); - stack.connect_visible_child_name_notify(move |st| { - sync_sidebar(&ui_nav); - match st.visible_child_name().as_deref() { - Some("browser") => load_browser(&ui_nav), - Some("gallery") => load_gallery(&ui_nav, false), - // Network-backed pages skip the fetch (and the "Loading…" flash) when - // the rows on screen are still fresh; the Retry button and mutations - // invalidate the timestamp to force a reload. - Some("sharedbyme") if page_fresh(&ui_nav.shared_by_me_loaded_at) => {} - Some("sharedbyme") => load_shared_by_me(&ui_nav), - Some("shared") if page_fresh(&ui_nav.shared_loaded_at) => {} - Some("shared") => load_shared(&ui_nav), - Some("devices") if page_fresh(&ui_nav.devices_loaded_at) => {} - Some("devices") => load_devices(&ui_nav), - // Activity is intentionally not TTL-cached: it changes out from under - // the page as background uploads and edits complete, so it reloads on - // every visit to stay live. - Some("activity") => load_activity(&ui_nav), - Some("trash") => load_trash(&ui_nav), - _ => {} - } - }); - - let window = adw::ApplicationWindow::builder() - .application(app) - .title("Proton Drive") - .default_width(980) - .default_height(680) - .content(&toasts) - .build(); - install_shortcuts(&ui, &window); - install_window_actions(&window); - - refresh(&ui); - // Periodic refresh while the window lives. The closure holds a strong `Rc`; - // it is dropped when the source is removed on window close. - let ui_tick = ui.clone(); - let source = glib::timeout_add_local(REFRESH_INTERVAL, move || { - refresh(&ui_tick); - glib::ControlFlow::Continue - }); - let cell = RefCell::new(Some(source)); - let ui_close = ui.clone(); - window.connect_close_request(move |_| { - if let Some(id) = cell.borrow_mut().take() { - id.remove(); - } - ui_close.save_ratios(); - glib::Propagation::Proceed - }); - - window.present(); -} - -/// The sidebar destinations, in order: the row index is the index into this table, -/// and each entry is `(stack page name, label, icon)`. -const DESTINATIONS: [(&str, &str, &str); 8] = [ - ("browser", "My files", "folder-symbolic"), - ("sharedbyme", "Shared", "emblem-shared-symbolic"), - ("shared", "Shared with me", "system-users-symbolic"), - ("devices", "Computers", "computer-symbolic"), - ("gallery", "Photos", "image-x-generic-symbolic"), - ("activity", "Activity", "document-open-recent-symbolic"), - ("trash", "Trash", "user-trash-symbolic"), - ("main", "Settings", "emblem-system-symbolic"), -]; - -/// The navigation sidebar: a Proton-branded header over one row per destination. -/// Returns the page (for the split view) and the list (to drive + reflect the -/// current page). -fn build_sidebar() -> (adw::NavigationPage, gtk4::ListBox) { - let list = gtk4::ListBox::new(); - list.set_selection_mode(gtk4::SelectionMode::Single); - list.add_css_class("navigation-sidebar"); - for (_, label, icon) in DESTINATIONS { - let row = adw::ActionRow::builder().title(label).build(); - row.add_prefix(>k4::Image::from_icon_name(icon)); - list.append(&row); - } - - let brand = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); - let icon = gtk4::Image::from_icon_name("folder-remote-symbolic"); - icon.add_css_class("brand-icon"); - brand.append(&icon); - brand.append(>k4::Label::new(Some("Proton Drive"))); - - let header = adw::HeaderBar::new(); - header.set_title_widget(Some(&brand)); - - let scroll = gtk4::ScrolledWindow::builder() - .hscrollbar_policy(gtk4::PolicyType::Never) - .vexpand(true) - .child(&list) - .build(); - let toolbar = adw::ToolbarView::new(); - toolbar.add_top_bar(&header); - toolbar.set_content(Some(&scroll)); - - let page = adw::NavigationPage::builder() - .title("Proton Drive") - .child(&toolbar) - .build(); - (page, list) -} - -/// Selecting a sidebar row navigates the page stack. The reverse direction (stack -/// → sidebar highlight) is [`sync_sidebar`], so the two can't fight: this handler -/// only ever writes the stack. -fn wire_sidebar(ui: &Rc) { - let ui_row = ui.clone(); - ui.sidebar.connect_row_selected(move |_, row| { - let Some(row) = row else { return }; - let Some((page, _, _)) = DESTINATIONS.get(row.index() as usize) else { - return; - }; - if ui_row.stack.visible_child_name().as_deref() != Some(*page) { - ui_row.stack.set_visible_child_name(page); - } - }); -} - -/// Whether a network-backed page painted good data within [`PAGE_TTL`] and so -/// can be reused without re-fetching. A `None` timestamp (never loaded, or -/// invalidated by a mutation) is always stale. -fn page_fresh(loaded_at: &Cell>) -> bool { - loaded_at.get().is_some_and(|t| t.elapsed() < PAGE_TTL) -} - -/// A page header's Refresh button. Every page that can show stale rows carries -/// one, so the user never has to guess whether what they're looking at is -/// current or wait out a TTL they can't see. -fn refresh_button() -> gtk4::Button { - let button = gtk4::Button::builder() - .icon_name("view-refresh-symbolic") - .tooltip_text("Refresh (F5)") - .valign(gtk4::Align::Center) - .build(); - button.add_css_class("flat"); - button -} - -/// Point every page's Refresh button at the current page. One handler for all of -/// them: the button acts on whatever is on screen, so it can't refresh a page the -/// user has since navigated away from. -fn wire_refresh(ui: &Rc, buttons: &[>k4::Button]) { - for button in buttons { - let ui = ui.clone(); - button.connect_clicked(move |_| reload_current_page(&ui)); - } -} - -/// Re-fetch the visible page from the server, bypassing every layer of cache -/// between it and the account. -/// -/// The two layers are separate: the daemon's own persisted listings (folders, -/// trash, photos) are dropped with [`Request::Refresh`] before re-asking, while -/// the pages the daemon always fetches live (sharing, devices, activity) only -/// need this front-end's [`PAGE_TTL`] stamp cleared. -fn reload_current_page(ui: &Rc) { - match ui.stack.visible_child_name().as_deref() { - Some("browser") => { - let path = ui.browser_path.borrow().clone(); - refresh_then(ui, RefreshScope::Dir { path }, load_browser); - } - Some("gallery") => refresh_then(ui, RefreshScope::Photos, |ui| load_gallery(ui, false)), - Some("trash") => refresh_then(ui, RefreshScope::Trash, load_trash), - Some("shared") => { - ui.shared_loaded_at.set(None); - load_shared(ui); - } - Some("sharedbyme") => { - ui.shared_by_me_loaded_at.set(None); - load_shared_by_me(ui); - } - Some("devices") => { - ui.devices_loaded_at.set(None); - load_devices(ui); - } - Some("activity") => load_activity(ui), - _ => {} - } -} - -/// Drop a daemon-side cached listing, then run the page's loader to re-fetch it. -/// -/// The loader runs even when the invalidation failed: it is the loader that knows -/// how to report an unreachable daemon on its own page, and a refresh that fails -/// silently would read as a dead button. -fn refresh_then(ui: &Rc, scope: RefreshScope, load: fn(&Rc)) { - let rx = spawn_request(ui.dirs.control_socket(), Request::Refresh { scope }); - let ui = ui.clone(); - glib::spawn_future_local(async move { - let _ = rx.recv().await; - load(&ui); - }); -} - -/// Highlight the sidebar row for whichever page the stack is showing, so -/// navigation that doesn't start in the sidebar (login landing on Files, the tray -/// raising the window) still moves the selection. -fn sync_sidebar(ui: &Rc) { - let Some(current) = ui.stack.visible_child_name() else { - return; - }; - let index = DESTINATIONS - .iter() - .position(|(page, _, _)| *page == current); - match index.and_then(|i| ui.sidebar.row_at_index(i as i32)) { - Some(row) => { - if ui.sidebar.selected_row().as_ref() != Some(&row) { - ui.sidebar.select_row(Some(&row)); - } - } - // The login page has no destination row. - None => ui.sidebar.unselect_all(), - } -} - -/// Show a transient toast. Non-blocking by design: an action's outcome is -/// reported without stealing focus or forcing a click, so the user can keep -/// working while a slow upload lands. -fn toast(ui: &Rc, message: &str) { - ui.toasts.add_toast(adw::Toast::new(message)); -} - -/// Show a toast for a failure. Same surface as [`toast`], but the message is -/// prefixed with what was being attempted, since a bare daemon error ("no such -/// file") reads as noise without it. -fn toast_error(ui: &Rc, what: &str, detail: &str) { - let detail = detail.trim(); - let message = if detail.is_empty() { - what.to_string() - } else { - format!("{what}: {detail}") - }; - tracing::warn!("{message}"); - let toast = adw::Toast::builder().title(&message).timeout(6).build(); - ui.toasts.add_toast(toast); -} - -/// The header's primary (hamburger) menu: the app-level entries that don't belong -/// on any one page. -fn build_primary_menu() -> gtk4::MenuButton { - let menu = gio::Menu::new(); - menu.append(Some("Keyboard Shortcuts"), Some("win.shortcuts")); - menu.append(Some("About Proton Drive"), Some("win.about")); - gtk4::MenuButton::builder() - .icon_name("open-menu-symbolic") - .tooltip_text("Main menu") - .menu_model(&menu) - .build() -} - -/// Back the primary menu's entries with window actions. -fn install_window_actions(window: &adw::ApplicationWindow) { - let shortcuts = gio::SimpleAction::new("shortcuts", None); - let win = window.clone(); - shortcuts.connect_activate(move |_, _| show_shortcuts(&win)); - window.add_action(&shortcuts); - - let about = gio::SimpleAction::new("about", None); - let win = window.clone(); - about.connect_activate(move |_, _| { - let dialog = adw::AboutDialog::builder() - .application_name("Proton Drive") - .application_icon("folder-remote-symbolic") - .version(pdfs_core::config::APP_VERSION) - .developer_name("proton-drive-linux") - .comments("On-demand Proton Drive sync for Linux.") - .build(); - dialog.present(Some(&win)); - }); - window.add_action(&about); -} - -/// The keyboard-shortcut cheatsheet behind the menu entry, listing exactly what -/// [`install_shortcuts`] binds. -fn show_shortcuts(window: &adw::ApplicationWindow) { - const KEYS: [(&str, &str); 6] = [ - ("Ctrl+F", "Search Drive"), - ("Ctrl+N", "New folder"), - ("Ctrl+U", "Upload file"), - ("F2", "Rename selection"), - ("Delete", "Move selection to Trash"), - ("Escape", "Close the details pane"), - ]; - let group = adw::PreferencesGroup::builder().title("Files").build(); - for (keys, action) in KEYS { - let row = adw::ActionRow::builder().title(action).build(); - let label = gtk4::Label::builder() - .label(keys) - .valign(gtk4::Align::Center) - .build(); - label.add_css_class("dim-label"); - label.add_css_class("monospace"); - row.add_suffix(&label); - group.add(&row); - } - let page = adw::PreferencesPage::new(); - page.add(&group); - - let dialog = adw::Dialog::builder() - .title("Keyboard Shortcuts") - .content_width(420) - .child(&{ - let toolbar = adw::ToolbarView::new(); - toolbar.add_top_bar(&adw::HeaderBar::new()); - toolbar.set_content(Some(&page)); - toolbar - }) - .build(); - dialog.present(Some(window)); -} - -/// Window-level keyboard shortcuts, so the browser is usable without the mouse: -/// Ctrl+F focuses search, Ctrl+N makes a folder, Ctrl+U uploads, F2 renames and -/// Delete trashes the selected entry, Escape closes the details pane. -fn install_shortcuts(ui: &Rc, window: &adw::ApplicationWindow) { - let controller = gtk4::EventControllerKey::new(); - let ui = ui.clone(); - controller.connect_key_pressed(move |_, key, _, state| { - let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK); - let on_browser = ui.stack.visible_child_name().as_deref() == Some("browser"); - match key { - // Refresh works on every page, so it is matched before the - // browser-only bindings. - gtk4::gdk::Key::F5 => reload_current_page(&ui), - gtk4::gdk::Key::r | gtk4::gdk::Key::R if ctrl => reload_current_page(&ui), - gtk4::gdk::Key::f | gtk4::gdk::Key::F if ctrl && on_browser => { - ui.browser_search.grab_focus(); - } - gtk4::gdk::Key::n | gtk4::gdk::Key::N if ctrl && on_browser => prompt_new_folder(&ui), - gtk4::gdk::Key::u | gtk4::gdk::Key::U if ctrl && on_browser => prompt_upload(&ui), - gtk4::gdk::Key::F2 if on_browser => { - if let Some(entry) = selected_entry(&ui) { - prompt_rename(&ui, &entry); - } - } - gtk4::gdk::Key::Delete if on_browser => { - if let Some(entry) = selected_entry(&ui) { - prompt_delete(&ui, &entry); - } - } - gtk4::gdk::Key::Escape if on_browser && ui.browser_split.shows_sidebar() => { - hide_details(&ui); - } - _ => return glib::Propagation::Proceed, - } - glib::Propagation::Stop - }); - window.add_controller(controller); -} - -/// The login page: an email row, password row, a primary "Sign in" button and a -/// status label, centred in a clamp. The 2FA code is prompted lazily in a -/// dialog (see [`prompt_2fa`]) only when the account actually requires it. -#[allow(clippy::type_complexity)] -fn build_login_page() -> ( - gtk4::Widget, - ( - adw::EntryRow, - adw::PasswordEntryRow, - gtk4::Button, - gtk4::Label, - ), -) { - let group = adw::PreferencesGroup::builder() - .title("Sign in to Proton") - .description("Use your Proton account to connect Drive.") - .build(); - - let email = adw::EntryRow::builder().title("Email or username").build(); - let password = adw::PasswordEntryRow::builder().title("Password").build(); - group.add(&email); - group.add(&password); - - let login_button = gtk4::Button::builder() - .label("Sign in") - .halign(gtk4::Align::Center) - .build(); - login_button.add_css_class("suggested-action"); - login_button.add_css_class("pill"); - - let login_status = gtk4::Label::builder() - .wrap(true) - .justify(gtk4::Justification::Center) - .build(); - login_status.add_css_class("dim-label"); - - let header = gtk4::Box::new(gtk4::Orientation::Vertical, 4); - let logo = gtk4::Image::from_icon_name("folder-remote-symbolic"); - logo.set_pixel_size(64); - logo.add_css_class("brand-icon"); - let title = gtk4::Label::new(Some("Proton Drive")); - title.add_css_class("brand-title"); - header.append(&logo); - header.append(&title); - header.set_margin_bottom(12); - - let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 16); - inner.set_margin_top(32); - inner.set_margin_bottom(32); - inner.set_margin_start(12); - inner.set_margin_end(12); - inner.append(&header); - inner.append(&group); - inner.append(&login_button); - inner.append(&login_status); - - let clamp = adw::Clamp::builder() - .maximum_size(420) - .child(&inner) - .build(); - let scroll = gtk4::ScrolledWindow::builder().child(&clamp).build(); - - ( - scroll.upcast(), - (email, password, login_button, login_status), - ) -} - -/// Widgets the settings page hands back for the refresh loop and action wiring. -struct MainWidgets { - account_row: adw::ActionRow, - mount_row: adw::ActionRow, - /// Live upload/download progress, populated by the refresh loop. - transfers_group: adw::PreferencesGroup, - 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(); - let avatar = adw::Avatar::new(40, None, true); - account_row.add_prefix(&avatar); - let logout_button = gtk4::Button::builder() - .label("Sign out") - .valign(gtk4::Align::Center) - .build(); - logout_button.add_css_class("flat"); - account_row.add_suffix(&logout_button); - account_group.add(&account_row); - - // Mount group: a read-only status line. The mount is managed automatically - // by the systemd user service; there is no toggle to fiddle with. - let mount_group = adw::PreferencesGroup::builder().title("Drive").build(); - let mount_row = adw::ActionRow::builder() - .title("Proton Drive") - .subtitle("Not mounted") - .build(); - mount_group.add(&mount_row); - - // Activity group: live upload/download progress. Hidden until the refresh - // loop sees an in-flight transfer from `Request::GetQueueStatus`. - let transfers_group = adw::PreferencesGroup::builder() - .title("Activity") - .description("Files moving to and from Proton Drive.") - .visible(false) - .build(); - - // 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.") - .build(); - let storage_box = gtk4::Box::new(gtk4::Orientation::Vertical, 6); - storage_box.set_margin_top(6); - storage_box.set_margin_bottom(6); - let cache_bar = gtk4::ProgressBar::new(); - let cache_label = gtk4::Label::builder().halign(gtk4::Align::Start).build(); - cache_label.add_css_class("dim-label"); - storage_box.append(&cache_bar); - storage_box.append(&cache_label); - 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() - .title("Pinned files") - .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); - inner.set_margin_start(12); - inner.set_margin_end(12); - inner.append(&account_group); - inner.append(&mount_group); - inner.append(&transfers_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) - .child(&inner) - .build(); - let scroll = gtk4::ScrolledWindow::builder().child(&clamp).build(); - - ( - scroll.upcast(), - MainWidgets { - account_row, - mount_row, - transfers_group, - cache_bar, - cache_label, - pins_group, - logout_button, - autostart_row, - budget_row, - purge_button, - mountpoint_row, - mountpoint_button, - }, - ) -} - -/// Connect the sign-in button: read the fields, run [`auth::login`] on a worker -/// thread, and report the outcome back on the main loop. -fn wire_login(ui: &Rc) { - // Pressing Enter in either field submits the form, so signing in never needs - // a reach for the mouse. Both fields route to the same button. - let btn = ui.login_button.clone(); - ui.email - .connect_entry_activated(move |_| btn.emit_clicked()); - let btn = ui.login_button.clone(); - ui.password - .connect_entry_activated(move |_| btn.emit_clicked()); - - let ui = ui.clone(); - let button = ui.login_button.clone(); - button.connect_clicked(move |_| { - let username = ui.email.text().to_string(); - let password = ui.password.text().to_string(); - if username.is_empty() || password.is_empty() { - ui.login_status.set_text("Enter your email and password."); - return; - } - - ui.login_button.set_sensitive(false); - ui.login_status.set_text("Signing in…"); - let (rx, totp_req_rx) = spawn_login(username, password); - - // Surface the 2FA dialog only if the SDK actually asks for a code (i.e. - // the account has 2FA enabled). The worker blocks until the dialog feeds - // back a code (or is cancelled, dropping the sender). - let ui_2fa = ui.clone(); - glib::spawn_future_local(async move { - if let Ok(code_tx) = totp_req_rx.recv().await { - prompt_2fa(&ui_2fa, code_tx); - } - }); - - let ui = ui.clone(); - glib::spawn_future_local(async move { - let result = rx - .recv() - .await - .unwrap_or_else(|_| Err("login cancelled".into())); - ui.login_button.set_sensitive(true); - match result { - Ok(()) => { - ui.login_status.set_text(""); - ui.password.set_text(""); - // Cache the new identity so `refresh` never hits the keyring. - *ui.session.borrow_mut() = auth::load().ok(); - // Enable+start the mount service now that we have a session. - service::enable_start(); - refresh(&ui); - } - Err(e) => ui.login_status.set_text(&format!("Sign-in failed: {e}")), - } - }); - }); -} - -/// Run the async SRP + optional 2FA login on a dedicated current-thread Tokio -/// runtime. Returns two channels: the first yields the final login result once; -/// the second fires *only if* the account needs a 2FA code, carrying a -/// [`std::sync::mpsc::Sender`] the UI uses to feed the code back. The login -/// closure blocks the worker on that sender until the dialog answers, so the -/// code is requested lazily and can't expire before the password proof. -#[allow(clippy::type_complexity)] -fn spawn_login( - username: String, - password: String, -) -> ( - async_channel::Receiver>, - async_channel::Receiver>, -) { - let (tx, rx) = async_channel::bounded(1); - let (totp_req_tx, totp_req_rx) = async_channel::bounded(1); - std::thread::spawn(move || { - let rt = match tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - { - Ok(rt) => rt, - Err(e) => { - let _ = tx.send_blocking(Err(e.to_string())); - return; - } - }; - let result = rt.block_on(async move { - auth::login(&username, &password, || { - // 2FA required: hand a one-shot sender to the UI and block until - // the dialog supplies the code. A dropped sender (cancelled - // dialog) surfaces as a cancelled login. - let (code_tx, code_rx) = std::sync::mpsc::channel::(); - totp_req_tx - .send_blocking(code_tx) - .map_err(|_| pdfs_core::Error::Other("two-factor prompt closed".into()))?; - code_rx - .recv() - .map_err(|_| pdfs_core::Error::Other("two-factor entry cancelled".into())) - }) - .await - .map_err(|e| e.to_string()) - }); - let _ = tx.send_blocking(result); - }); - (rx, totp_req_rx) -} - -/// Show the lazy two-factor dialog and feed the entered code back to the waiting -/// login worker via `code_tx`. Cancelling (or closing) drops the sender, which -/// the worker reads as a cancelled login. -fn prompt_2fa(ui: &Rc, code_tx: std::sync::mpsc::Sender) { - let dialog = adw::AlertDialog::builder() - .heading("Two-factor authentication") - .body("Enter the code from your authenticator app.") - .build(); - - let group = adw::PreferencesGroup::new(); - let entry = adw::EntryRow::builder() - .title("Authentication code") - .activates_default(true) - .build(); - group.add(&entry); - dialog.set_extra_child(Some(&group)); - - dialog.add_response("cancel", "Cancel"); - dialog.add_response("confirm", "Confirm"); - dialog.set_response_appearance("confirm", adw::ResponseAppearance::Suggested); - dialog.set_default_response(Some("confirm")); - dialog.set_close_response("cancel"); - - let code_tx = RefCell::new(Some(code_tx)); - dialog.connect_response(None, move |_, resp| { - // On cancel/close we take + drop `tx` without sending, so the worker's - // recv errors out and the login is reported as cancelled. - if let Some(tx) = code_tx.borrow_mut().take() - && resp == "confirm" - { - let _ = tx.send(entry.text().trim().to_string()); - } - }); - - let parent = ui.login_button.root().and_downcast::(); - dialog.present(parent.as_ref()); -} - -/// Connect the sign-out button: disable+stop the mount service (so the daemon -/// isn't left running without credentials), forget the stored session, and drop -/// back to the login page. -fn wire_logout(ui: &Rc, button: >k4::Button) { - let ui = ui.clone(); - button.connect_clicked(move |_| { - service::disable_stop(); - if let Err(e) = auth::logout() { - tracing::error!("logout failed: {e}"); - } - *ui.session.borrow_mut() = None; - refresh(&ui); - }); -} - -/// 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 }, - "Cache budget updated", - "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, - "Cache purged", - "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, -/// confirming with `done` or reporting the daemon's error under `failed`. Unlike -/// [`run_mutation`] there's no browser reload; the next refresh tick repaints the -/// cache read-out. -fn settings_request(ui: &Rc, req: Request, done: &'static str, failed: &'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 { .. })) => toast(&ui, done), - Ok(Ok(Response::Error { message })) => toast_error(&ui, failed, &message), - _ => toast_error(&ui, failed, "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) { - toast_error(&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. -fn wire_retry(ui: &Rc) { - let ui_browser = ui.clone(); - ui.browser_retry.clone().connect_clicked(move |_| { - service::restart(); - load_browser(&ui_browser); - }); - let ui_gallery = ui.clone(); - ui.gallery_retry.clone().connect_clicked(move |_| { - service::restart(); - load_gallery(&ui_gallery, false); - }); - let ui_trash = ui.clone(); - ui.trash_retry.clone().connect_clicked(move |_| { - service::restart(); - load_trash(&ui_trash); - }); -} - -/// Repaint the window from the cached login identity, then kick an async mount- -/// status fetch. Runs on the 2s tick: the identity check is instant (no keyring), -/// and the status round-trip is offloaded to a worker so the main loop never -/// blocks on a slow or wedged daemon. -fn refresh(ui: &Rc) { - // Login identity decides which page is shown. Read the cached session — set - // at startup and on login/logout — never the keyring. - { - let session = ui.session.borrow(); - match session.as_ref() { - Some(s) => { - // Only pull the user onto a destination when they're sitting on the - // login page; otherwise leave whichever page they navigated to. - if ui.stack.visible_child_name().as_deref() == Some("login") { - ui.stack.set_visible_child_name("browser"); - } - ui.nav.set_collapsed(false); - ui.account_row.set_title(&s.username); - ui.account_row.set_subtitle("Proton account"); - } - None => { - ui.stack.set_visible_child_name("login"); - // Collapsed + showing content = the login page owns the window and - // no destination is reachable without a session. - ui.nav.set_collapsed(true); - ui.nav.set_show_content(true); - return; - } - } - } - - refresh_status(ui); - refresh_transfers(ui); - // Both of these pages show work as it happens, so they follow the tick while - // they are on screen. Every other page loads on navigation only. - match ui.stack.visible_child_name().as_deref() { - Some("devices") => refresh_sync_folders(ui), - Some("activity") => refresh_activity(ui), - _ => {} - } -} - -/// Record the mount state seen by the last status poll: gate every control that -/// needs a live daemon, and notify the desktop when the state actually flips. -/// -/// The gating is the point — without it, New Folder / Upload / the details pane's -/// actions stay clickable while the mount is down and each click buys a round-trip -/// that can only fail. A greyed control says so up front. -fn set_mounted(ui: &Rc, mounted: bool) { - *ui.mounted.borrow_mut() = mounted; - ui.browser_new_folder.set_sensitive(mounted); - ui.browser_upload.set_sensitive(mounted); - ui.browser_upload_folder.set_sensitive(mounted); - ui.gallery_upload.set_sensitive(mounted); - ui.details.pin_row.set_sensitive(mounted); - ui.details.rename_button.set_sensitive(mounted); - ui.details.trash_button.set_sensitive(mounted); - ui.details.open_button.set_sensitive(mounted); - - // Only notify on a real edge, and never for the first reading: at startup the - // service is usually still coming up, and "disconnected" would be a lie. - if ui.notified_mounted.get() == Some(mounted) { - return; - } - let first = ui.notified_mounted.replace(Some(mounted)).is_none(); - if first { - return; - } - if mounted { - notify( - "mount-state", - "Proton Drive connected", - "Your Drive is mounted and available.", - ); - } else { - notify( - "mount-state", - "Proton Drive disconnected", - "The mount service stopped. Files aren't available until it restarts.", - ); - } -} - -/// Send a desktop notification through the app's GIO channel. `id` replaces any -/// earlier notification with the same id, so a flapping mount updates one -/// notification instead of stacking a column of them. -fn notify(id: &str, title: &str, body: &str) { - let Some(app) = gio::Application::default() else { - return; - }; - let notification = gio::Notification::new(title); - notification.set_body(Some(body)); - notification.set_priority(gio::NotificationPriority::Low); - app.send_notification(Some(id), ¬ification); -} - -/// Poll the daemon's in-flight transfers on a worker thread and repaint the -/// Activity group. Independently inflight-guarded from [`refresh_status`] so the -/// two cheap polls on the 2s tick don't gate each other. The group hides itself -/// when nothing is moving, so an idle account shows no Activity section. -fn refresh_transfers(ui: &Rc) { - if ui.transfers_inflight.get() { - return; - } - ui.transfers_inflight.set(true); - let rx = spawn_request(ui.dirs.control_socket(), Request::GetQueueStatus); - let ui = ui.clone(); - glib::spawn_future_local(async move { - let result = rx.recv().await; - ui.transfers_inflight.set(false); - match result { - Ok(Ok(Response::Transfers { items, jobs })) => repaint_transfers(&ui, &items, &jobs), - // Daemon unreachable or odd reply: clear the section rather than - // leave stale progress bars frozen on screen. - _ => repaint_transfers(&ui, &[], &[]), - } - }); -} - -/// Render the Activity group from a work snapshot: the daemon's jobs (scans, -/// batch counts, the local index) above the files moving under them. Rebuilds the -/// rows only when the set changes (count differs); on the common steady tick it -/// updates each bar's fraction and the label in place, so progress animates -/// without flicker. Hides the whole group when the daemon is idle. -fn repaint_transfers(ui: &Rc, items: &[TransferItem], jobs: &[JobItem]) { - let lines: Vec = jobs - .iter() - .map(job_line) - .chain(items.iter().map(transfer_line)) - .collect(); - - // The wire carries in-flight transfers only, with no completion event: the - // count falling to zero is what "the sync finished" looks like from here. - // Jobs are deliberately not counted — a bulk upload retires its scan job and - // starts its upload job mid-flight, which is not a thing finishing. - let previous = ui.active_transfers.replace(items.len()); - if items.is_empty() && previous > 0 { - let files = if previous == 1 { - "1 file".to_string() - } else { - format!("{previous} files") - }; - notify( - "sync-complete", - "Sync complete", - &format!("{files} finished transferring."), - ); - // A just-finished batch may have added files (bulk upload) the current - // listing doesn't show yet; refresh whichever listing is on screen. - reload_listing(ui); - } - - if lines.is_empty() { - if !ui.transfer_rows.borrow().is_empty() { - for tr in ui.transfer_rows.borrow_mut().drain(..) { - ui.transfers_group.remove(&tr.row); - } - } - ui.transfers_group.set_visible(false); - return; - } - - ui.transfers_group.set_visible(true); - - // Rebuild rows only when the count changes; otherwise reuse them in place. - if ui.transfer_rows.borrow().len() != lines.len() { - for tr in ui.transfer_rows.borrow_mut().drain(..) { - ui.transfers_group.remove(&tr.row); - } - for _ in &lines { - let row_box = gtk4::Box::new(gtk4::Orientation::Vertical, 4); - row_box.set_margin_top(8); - row_box.set_margin_bottom(8); - let label = gtk4::Label::builder().halign(gtk4::Align::Start).build(); - label.add_css_class("dim-label"); - let bar = gtk4::ProgressBar::new(); - row_box.append(&label); - row_box.append(&bar); - let row = adw::PreferencesRow::builder() - .activatable(false) - .child(&row_box) - .build(); - ui.transfers_group.add(&row); - ui.transfer_rows - .borrow_mut() - .push(TransferRow { row, label, bar }); - } - } - - for (line, tr) in lines.iter().zip(ui.transfer_rows.borrow().iter()) { - tr.label.set_text(&line.text); - match line.fraction { - Some(f) => tr.bar.set_fraction(f), - // No total to divide by: pulse so the bar still reads as "working". - None => tr.bar.pulse(), - } - } -} - -/// One Activity row for a daemon job: its title, plus whatever it can say about -/// where it is — a count when it has one, else what it is currently chewing on. -fn job_line(j: &JobItem) -> ActivityLine { - let text = match (j.total > 0, j.detail.is_empty()) { - (true, true) => format!("{} — {} of {}", j.title, j.done, j.total), - (true, false) => format!("{} — {} ({} of {})", j.title, j.detail, j.done, j.total), - (false, true) => format!("{}…", j.title), - (false, false) => format!("{} — {}…", j.title, j.detail), - }; - ActivityLine { - text, - fraction: (j.total > 0).then(|| (j.done as f64 / j.total as f64).min(1.0)), - } -} - -/// One Activity row for a file in flight: which way it's going, how far, how fast. -fn transfer_line(t: &TransferItem) -> ActivityLine { - let arrow = match t.direction { - TransferDirection::Download => "↓", - TransferDirection::Upload => "↑", - }; - if t.bytes_total == 0 { - ActivityLine { - text: format!( - "{arrow} {} — {} ({}/s)", - t.name, - human_bytes(t.bytes_completed), - human_bytes(t.speed_bytes_sec), - ), - fraction: None, - } - } else { - ActivityLine { - text: format!( - "{arrow} {} — {} of {} ({}/s)", - t.name, - human_bytes(t.bytes_completed), - human_bytes(t.bytes_total), - human_bytes(t.speed_bytes_sec), - ), - fraction: Some((t.bytes_completed as f64 / t.bytes_total as f64).min(1.0)), - } - } -} - -/// Fetch mount status + cache stats from the daemon on a worker thread and repaint -/// the mount line, cache bar and pin list on the reply. The daemon owns the cache -/// stats now (`used`/`budget`/`pins` ride along on [`Response::Status`]), so the -/// GUI never opens the on-disk cache itself. Skipped while a fetch is in flight so -/// the tick can't stack threads on a stalled daemon. -fn refresh_status(ui: &Rc) { - if ui.status_inflight.get() { - return; - } - ui.status_inflight.set(true); - let rx = spawn_request(ui.dirs.control_socket(), Request::Status); - let ui = ui.clone(); - glib::spawn_future_local(async move { - let result = rx.recv().await; - ui.status_inflight.set(false); - match result { - Ok(Ok(Response::Status { - mountpoint, - used, - budget, - pins, - online, - pending_uploads, - pending_changes, - .. - })) => { - set_mounted(&ui, true); - // The queue is the more useful thing to say when it has anything - // in it: it is why a file that looks saved is not on the remote - // yet, and offline is usually the reason it is still queued. - let queued = pending_summary(pending_uploads, pending_changes); - ui.mount_row.set_subtitle(&match (online, queued) { - (true, None) => format!("Mounted at {mountpoint}"), - (true, Some(q)) => format!("Mounted at {mountpoint} — {q}"), - (false, None) => { - format!("Mounted at {mountpoint} — offline, cached files only") - } - (false, Some(q)) => format!("Mounted at {mountpoint} — offline, {q}"), - }); - let fraction = if budget == 0 { - 0.0 - } else { - (used as f64 / budget as f64).min(1.0) - }; - ui.cache_bar.set_fraction(fraction); - ui.cache_label.set_text(&format!( - "{} of {} used", - human_bytes(used), - human_bytes(budget) - )); - repaint_pins(&ui, &pins, true); - } - // Daemon unreachable (still starting, or down): report not-mounted and - // grey out the unpin buttons in place, but leave the last-known pin - // rows and cache read-out so the page doesn't flicker on a blip. - _ => { - set_mounted(&ui, false); - ui.mount_row.set_subtitle("Not mounted"); - for r in ui.pin_rows.borrow().iter() { - if let Some(b) = &r.unpin { - b.set_sensitive(false); - } - } - } - } - }); -} - -/// Render the pins group from `pins`, with the unpin buttons enabled only while a -/// mount daemon is running (`mounted`). Diffs against the last batch by path: when -/// the set is unchanged (the common case on the 2s tick) it only flips the unpin -/// buttons' `sensitive` flag, avoiding the rebuild that used to flicker the list -/// and drop scroll/selection every tick. -fn repaint_pins(ui: &Rc, pins: &[pdfs_core::cache::Pin], mounted: bool) { - let desired: Vec = pins.iter().map(|p| p.path.clone()).collect(); - if ui.pins_state.borrow().as_ref() == Some(&desired) { - for r in ui.pin_rows.borrow().iter() { - if let Some(b) = &r.unpin { - b.set_sensitive(mounted); - } - } - return; - } - - for pr in ui.pin_rows.borrow_mut().drain(..) { - ui.pins_group.remove(&pr.row); - } - *ui.pins_state.borrow_mut() = Some(desired); - - if pins.is_empty() { - let row = adw::ActionRow::builder() - .title("No pinned files") - .subtitle("Right-click a file in the mount to keep it offline.") - .build(); - ui.pins_group.add(&row); - ui.pin_rows.borrow_mut().push(PinRow { row, unpin: None }); - return; - } - - for pin in pins { - let name = Path::new(&pin.path) - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or(&pin.path) - .to_string(); - let row = adw::ActionRow::builder() - .title(&name) - .subtitle(&pin.path) - .build(); - let icon = gtk4::Image::from_icon_name("emblem-documents-symbolic"); - row.add_prefix(&icon); - - let unpin = gtk4::Button::builder() - .icon_name("user-trash-symbolic") - .valign(gtk4::Align::Center) - .tooltip_text("Unpin (remove offline copy)") - .sensitive(mounted) - .build(); - unpin.add_css_class("flat"); - let ui_btn = ui.clone(); - let path = pin.path.clone(); - unpin.connect_clicked(move |_| { - let socket = ui_btn.dirs.control_socket(); - match send(&socket, &Request::Unpin { path: path.clone() }) { - Ok(Response::Error { message }) => tracing::error!("unpin failed: {message}"), - Ok(_) => refresh(&ui_btn), - Err(e) => tracing::error!("unpin request failed: {e}"), - } - }); - row.add_suffix(&unpin); - - ui.pins_group.add(&row); - ui.pin_rows.borrow_mut().push(PinRow { - row, - unpin: Some(unpin), - }); - } -} - -/// Run one blocking control-socket round-trip on a worker thread, returning a -/// channel that yields the [`Response`] once. Browser/gallery requests reach the -/// network through the daemon, so they must not block the GTK main loop the way -/// the cheap [`Request::Status`] poll in [`refresh`] can. -fn spawn_request( - socket: PathBuf, - req: Request, -) -> async_channel::Receiver> { - let (tx, rx) = async_channel::bounded(1); - std::thread::spawn(move || { - let result = send(&socket, &req).map_err(|e| e.to_string()); - let _ = tx.send_blocking(result); - }); - rx -} - -/// The Files page: a Nautilus-style file manager. A back/breadcrumb header with -/// a grid/list view toggle sits over a [`gtk4::Stack`] that swaps between an -/// **icon grid** ([`gtk4::GridView`]) and a **column list** ([`gtk4::ColumnView`] -/// with Name / Size / Modified columns). Both views are driven by one shared -/// [`gio::ListStore`] of [`BoxedAnyObject`]-wrapped [`DirEntry`]s, so a directory -/// load repopulates the model once and both views update. -/// -/// The factories that render entries — and the columns — need the [`Ui`] handle -/// for activation and the right-click menu, so they're installed later in -/// [`wire_browser`]; this builder only assembles the empty widgets. -/// -/// Empty / loading / error outcomes aren't a label under the header: the whole -/// content area swaps to a centred [`adw::StatusPage`] (see [`browser_status`]), -/// so "this folder is empty" and "the mount is down" read as first-class states -/// rather than a stray line above a blank grid. -struct BrowserWidgets { - model: gio::ListStore, - back: gtk4::Button, - crumb: gtk4::Box, - grid: gtk4::GridView, - column_view: gtk4::ColumnView, - /// Swaps the content area between the grid/list views and the status page. - content: gtk4::Stack, - /// The empty/loading/error surface shown in place of the views. - status: adw::StatusPage, - /// Sits in the status page; shown only when the mount service is down. - retry: gtk4::Button, - search: gtk4::SearchEntry, - new_folder: gtk4::Button, - upload: gtk4::Button, - upload_folder: gtk4::Button, - refresh: gtk4::Button, - /// Wraps the views + the details pane; the pane slides in on selection. - split: adw::OverlaySplitView, - details: DetailsWidgets, - /// The two selection models, so a selection change can drive the details pane - /// and so an action can re-read the entry the user has highlighted. - grid_selection: gtk4::SingleSelection, - list_selection: gtk4::SingleSelection, -} - -fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) { - let model = gio::ListStore::new::(); - - let back = gtk4::Button::builder() - .icon_name("go-previous-symbolic") - .tooltip_text("Up one folder") - .valign(gtk4::Align::Center) - .sensitive(false) - .build(); - back.add_css_class("flat"); - // 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) - .child(&crumb) - .build(); - - // 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 files") - .valign(gtk4::Align::Center) - .build(); - upload.add_css_class("flat"); - let upload_folder = gtk4::Button::builder() - .icon_name("pdfs-folder-upload-symbolic") - .tooltip_text("Upload folder") - .valign(gtk4::Align::Center) - .build(); - upload_folder.add_css_class("flat"); - - // Linked grid/list toggle, top-right, Nautilus-style. - let grid_toggle = gtk4::ToggleButton::builder() - .icon_name("view-grid-symbolic") - .tooltip_text("Grid view") - .active(true) - .build(); - let list_toggle = gtk4::ToggleButton::builder() - .icon_name("view-list-symbolic") - .tooltip_text("List view") - .build(); - list_toggle.set_group(Some(&grid_toggle)); - let toggles = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); - toggles.add_css_class("linked"); - toggles.append(&grid_toggle); - toggles.append(&list_toggle); - - let search = gtk4::SearchEntry::builder() - .placeholder_text("Search Drive") - .valign(gtk4::Align::Center) - .build(); - search.set_width_chars(18); - - let refresh = refresh_button(); - - let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); - header.append(&back); - header.append(&crumb_scroll); - header.append(&refresh); - header.append(&new_folder); - header.append(&upload); - header.append(&upload_folder); - header.append(&search); - header.append(&toggles); - - // Empty / loading / error surface, shown in place of the views. - let retry = gtk4::Button::builder() - .label("Retry") - .halign(gtk4::Align::Center) - .build(); - retry.add_css_class("pill"); - retry.add_css_class("suggested-action"); - retry.set_visible(false); - let status = adw::StatusPage::builder() - .icon_name("folder-symbolic") - .vexpand(true) - .child(&retry) - .build(); - status.add_css_class("compact"); - - // Icon grid. - let grid_selection = gtk4::SingleSelection::builder() - .model(&model) - .autoselect(false) - .can_unselect(true) - .build(); - let grid = gtk4::GridView::builder() - .model(&grid_selection) - .min_columns(2) - .max_columns(10) - .build(); - grid.add_css_class("file-grid"); - let grid_scroll = gtk4::ScrolledWindow::builder() - .vexpand(true) - .child(&grid) - .build(); - - // Column list. - let list_selection = gtk4::SingleSelection::builder() - .model(&model) - .autoselect(false) - .can_unselect(true) - .build(); - let column_view = gtk4::ColumnView::builder().model(&list_selection).build(); - column_view.add_css_class("data-table"); - let column_scroll = gtk4::ScrolledWindow::builder() - .vexpand(true) - .child(&column_view) - .build(); - - // Stack swapped by the toggle buttons. - let view_stack = gtk4::Stack::new(); - view_stack.set_vexpand(true); - view_stack.add_named(&grid_scroll, Some("grid")); - view_stack.add_named(&column_scroll, Some("list")); - let vs = view_stack.clone(); - grid_toggle.connect_toggled(move |b| { - if b.is_active() { - vs.set_visible_child_name("grid"); - } - }); - let vs = view_stack.clone(); - list_toggle.connect_toggled(move |b| { - if b.is_active() { - vs.set_visible_child_name("list"); - } - }); - - // Outer stack: the views, or the status page when there's nothing to show. - let content = gtk4::Stack::new(); - content.set_vexpand(true); - content.set_transition_type(gtk4::StackTransitionType::Crossfade); - content.add_named(&view_stack, Some("views")); - content.add_named(&status, Some("status")); - - // The details pane slides in from the right when an entry is selected. - let (details_pane, details) = build_details_pane(); - let split = adw::OverlaySplitView::builder() - .sidebar_position(gtk4::PackType::End) - .collapsed(true) - .show_sidebar(false) - .max_sidebar_width(300.0) - .content(&content) - .sidebar(&details_pane) - .build(); - - let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12); - inner.set_margin_top(12); - inner.set_margin_bottom(12); - inner.set_margin_start(12); - inner.set_margin_end(12); - inner.append(&header); - inner.append(&split); - - ( - inner.upcast(), - BrowserWidgets { - model, - back, - crumb, - grid, - column_view, - content, - status, - retry, - search, - new_folder, - upload, - upload_folder, - refresh, - split, - details, - grid_selection, - list_selection, - }, - ) -} - -/// The widgets in the browser's details pane that a selection repaints. -struct DetailsWidgets { - icon: gtk4::Image, - name: gtk4::Label, - kind: gtk4::Label, - size_row: adw::ActionRow, - modified_row: adw::ActionRow, - path_row: adw::ActionRow, - pin_row: adw::SwitchRow, - open_button: gtk4::Button, - rename_button: gtk4::Button, - trash_button: gtk4::Button, - close_button: gtk4::Button, -} - -/// The details pane shown beside the file views: a big type icon over the entry's -/// name, its properties, an offline (pin) toggle and the primary actions. Built -/// empty; [`repaint_details`] fills it from the selected [`DirEntry`] and -/// [`wire_details`] connects the buttons. -fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) { - let close_button = gtk4::Button::builder() - .icon_name("window-close-symbolic") - .tooltip_text("Close details") - .halign(gtk4::Align::End) - .build(); - close_button.add_css_class("flat"); - close_button.add_css_class("circular"); - - let icon = gtk4::Image::builder() - .icon_name("text-x-generic-symbolic") - .pixel_size(64) - .build(); - let name = gtk4::Label::builder() - .wrap(true) - .wrap_mode(gtk4::pango::WrapMode::WordChar) - .justify(gtk4::Justification::Center) - .build(); - name.add_css_class("title-4"); - let kind = gtk4::Label::new(None); - kind.add_css_class("dim-label"); - kind.add_css_class("caption"); - - let head = gtk4::Box::new(gtk4::Orientation::Vertical, 6); - head.set_margin_bottom(6); - head.append(&icon); - head.append(&name); - head.append(&kind); - - let props = adw::PreferencesGroup::new(); - let size_row = adw::ActionRow::builder() - .title("Size") - .subtitle("—") - .build(); - size_row.add_css_class("property"); - let modified_row = adw::ActionRow::builder() - .title("Modified") - .subtitle("—") - .build(); - modified_row.add_css_class("property"); - let path_row = adw::ActionRow::builder() - .title("Location") - .subtitle("—") - .subtitle_lines(3) - .build(); - path_row.add_css_class("property"); - props.add(&size_row); - props.add(&modified_row); - props.add(&path_row); - - let offline = adw::PreferencesGroup::new(); - let pin_row = adw::SwitchRow::builder() - .title("Available offline") - .subtitle("Keep a local copy on this device.") - .build(); - offline.add(&pin_row); - - let open_button = gtk4::Button::builder().label("Open").build(); - open_button.add_css_class("suggested-action"); - open_button.add_css_class("pill"); - let rename_button = gtk4::Button::builder().label("Rename").build(); - rename_button.add_css_class("pill"); - let trash_button = gtk4::Button::builder().label("Move to Trash").build(); - trash_button.add_css_class("destructive-action"); - trash_button.add_css_class("pill"); - - let actions = gtk4::Box::new(gtk4::Orientation::Vertical, 6); - actions.set_margin_top(6); - actions.append(&open_button); - actions.append(&rename_button); - actions.append(&trash_button); - - let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12); - inner.set_margin_top(12); - inner.set_margin_bottom(12); - inner.set_margin_start(12); - inner.set_margin_end(12); - inner.append(&close_button); - inner.append(&head); - inner.append(&props); - inner.append(&offline); - inner.append(&actions); - - let scroll = gtk4::ScrolledWindow::builder() - .hscrollbar_policy(gtk4::PolicyType::Never) - .child(&inner) - .build(); - let pane = adw::ToolbarView::new(); - pane.set_content(Some(&scroll)); - pane.add_css_class("background"); - - ( - pane.upcast(), - DetailsWidgets { - icon, - name, - kind, - size_row, - modified_row, - path_row, - pin_row, - open_button, - rename_button, - trash_button, - close_button, - }, - ) -} - -/// Connect the details pane: mirror the grid's and list's selection into it, and -/// wire its buttons back onto the entry it's showing. -fn wire_details(ui: &Rc) { - // Both views share one model but have their own selection, so watch both. - for selection in [ui.grid_selection.clone(), ui.list_selection.clone()] { - let ui_sel = ui.clone(); - selection.connect_selection_changed(move |sel, _, _| { - match entry_at(sel.model().as_ref(), sel.selected()) { - Some(entry) => show_details(&ui_sel, &entry), - None => hide_details(&ui_sel), - } - }); - } - - let ui_close = ui.clone(); - ui.details.close_button.connect_clicked(move |_| { - ui_close - .grid_selection - .set_selected(gtk4::INVALID_LIST_POSITION); - ui_close - .list_selection - .set_selected(gtk4::INVALID_LIST_POSITION); - hide_details(&ui_close); - }); - - let ui_open = ui.clone(); - ui.details.open_button.connect_clicked(move |_| { - // Bind the clone to a local so the `Ref` from `borrow()` is dropped before - // `activate_entry` runs; it navigates and repaints the details pane, which - // takes `details_entry.borrow_mut()` and would panic against a live borrow. - let entry = ui_open.details_entry.borrow().clone(); - if let Some(entry) = entry { - activate_entry(&ui_open, &entry); - } - }); - let ui_rename = ui.clone(); - ui.details.rename_button.connect_clicked(move |_| { - let entry = ui_rename.details_entry.borrow().clone(); - if let Some(entry) = entry { - prompt_rename(&ui_rename, &entry); - } - }); - let ui_trash = ui.clone(); - ui.details.trash_button.connect_clicked(move |_| { - let entry = ui_trash.details_entry.borrow().clone(); - if let Some(entry) = entry { - prompt_delete(&ui_trash, &entry); - } - }); - let ui_pin = ui.clone(); - ui.details.pin_row.connect_active_notify(move |row| { - if ui_pin.details_suppress.get() { - return; - } - let Some(entry) = ui_pin.details_entry.borrow().clone() else { - return; - }; - // The switch reads the *desired* state; `toggle_pin` derives the request - // from the entry's current one, so only act when they actually differ. - if row.is_active() != entry.pinned { - toggle_pin(&ui_pin, &entry); - } - }); -} - -/// Reveal the details pane and paint it from `entry`. -fn show_details(ui: &Rc, entry: &DirEntry) { - ui.details_suppress.set(true); - let d = &ui.details; - d.icon.set_icon_name(Some(icon_base_for(entry))); - d.name.set_label(&entry.name); - d.kind - .set_label(if entry.is_dir { "Folder" } else { "File" }); - d.size_row.set_subtitle(&if entry.is_dir { - "—".to_string() - } else { - human_bytes(entry.size) - }); - d.size_row.set_visible(!entry.is_dir); - d.modified_row - .set_subtitle(&format_modified(entry.modified)); - let rel = entry_rel(ui, entry); - let parent = match rel.rfind('/') { - Some(i) => &rel[..i], - None => "", - }; - d.path_row.set_subtitle(if parent.is_empty() { - "Proton Drive" - } else { - parent - }); - d.pin_row.set_active(entry.pinned); - d.pin_row.set_sensitive(*ui.mounted.borrow()); - d.open_button - .set_label(if entry.is_dir { "Open folder" } else { "Open" }); - ui.details_suppress.set(false); - - *ui.details_entry.borrow_mut() = Some(entry.clone()); - // Reveal the pane on idle, not inline. This runs from `selection_changed`, - // which fires on the *first* press of a double-click; mutating the widget - // tree here cancels GtkGridView's multi-press tracking, so the second press - // restarts the count and `activate` never fires — the folder needs two - // double-clicks. Deferring lets the click gesture finish first. Guard on the - // entry still being present so a navigation that clears it wins the race. - let ui = ui.clone(); - glib::idle_add_local_once(move || { - if ui.details_entry.borrow().is_some() { - ui.browser_split.set_show_sidebar(true); - } - }); -} - -/// Hide the details pane and forget the entry it was showing, so a stale entry -/// can't be acted on after the listing moves on. -fn hide_details(ui: &Rc) { - ui.browser_split.set_show_sidebar(false); - *ui.details_entry.borrow_mut() = None; -} - -/// The entry highlighted in whichever browser view is on screen, if any. Backs the -/// F2 / Delete shortcuts. -fn selected_entry(ui: &Rc) -> Option { - ui.details_entry.borrow().clone() -} - -/// Swap the Files content area to the status page, with a Retry button only when -/// the failure is one the user can act on. -fn browser_status(ui: &Rc, icon: &str, title: &str, description: &str, retry: bool) { - ui.browser_status.set_icon_name(Some(icon)); - ui.browser_status.set_title(title); - ui.browser_status.set_description(Some(description)); - ui.browser_retry.set_visible(retry); - ui.browser_content.set_visible_child_name("status"); - hide_details(ui); -} - -/// Swap the Files content area back to the grid/list views. -fn browser_views(ui: &Rc) { - ui.browser_content.set_visible_child_name("views"); -} - -/// Install the entry factories, columns, activation handlers and the back -/// button. Split out from [`build_browser_page`] because every renderer needs -/// the [`Ui`] handle to open entries and raise the context menu. -fn wire_browser(ui: &Rc, grid: >k4::GridView, column_view: >k4::ColumnView) { - // Back: pop one path segment and reload. - let ui_back = ui.clone(); - ui.browser_back.clone().connect_clicked(move |_| { - { - let mut path = ui_back.browser_path.borrow_mut(); - *path = match path.rfind('/') { - Some(i) => path[..i].to_string(), - None => String::new(), - }; - } - load_browser(&ui_back); - }); - - // Grid tiles: a big icon over an ellipsized name, with a right-click menu. - let factory = gtk4::SignalListItemFactory::new(); - factory.connect_setup({ - let ui = ui.clone(); - 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) - .max_width_chars(13) - .width_chars(13) - .wrap(true) - .lines(2) - .build(); - let tile = gtk4::Box::new(gtk4::Orientation::Vertical, 4); - tile.add_css_class("file-tile"); - 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 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(&entry.name); - apply_badge(&badge, &entry); - }); - grid.set_factory(Some(&factory)); - - let ui_grid = ui.clone(); - grid.connect_activate(move |grid, pos| { - if let Some(entry) = entry_at(grid.model().as_ref(), pos) { - activate_entry(&ui_grid, &entry); - } - }); - - // Column list: Name (icon + label, right-clickable), Size, Modified. - column_view.append_column(&name_column(ui)); - column_view.append_column(&text_column("Size", |e| { - if e.is_dir { - "—".to_string() - } else { - human_bytes(e.size) - } - })); - column_view.append_column(&text_column("Modified", |e| format_modified(e.modified))); - - let ui_col = ui.clone(); - column_view.connect_activate(move |view, pos| { - if let Some(entry) = entry_at(view.model().as_ref(), pos) { - activate_entry(&ui_col, &entry); - } - }); -} - -/// Build the Name column: a small icon plus the (star-prefixed when pinned) name, -/// with the same right-click menu the grid tiles carry. -fn name_column(ui: &Rc) -> gtk4::ColumnViewColumn { - let factory = gtk4::SignalListItemFactory::new(); - factory.connect_setup({ - let ui = ui.clone(); - move |_, item| { - 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)); - } - }); - factory.connect_bind(|_, item| { - let item = item.downcast_ref::().unwrap(); - let cell = item.child().and_downcast::().unwrap(); - let icon = cell.first_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(&entry.name); - apply_badge(&badge, &entry); - }); - let column = gtk4::ColumnViewColumn::new(Some("Name"), Some(factory)); - column.set_expand(true); - column -} - -/// Build a trailing text column whose cell text is derived from each [`DirEntry`] -/// by `render`. -fn text_column( - title: &str, - render: impl Fn(&DirEntry) -> String + 'static, -) -> gtk4::ColumnViewColumn { - let factory = gtk4::SignalListItemFactory::new(); - factory.connect_setup(|_, item| { - let item = item.downcast_ref::().unwrap(); - let label = gtk4::Label::builder().halign(gtk4::Align::Start).build(); - label.add_css_class("dim-label"); - item.set_child(Some(&label)); - }); - factory.connect_bind(move |_, item| { - let item = item.downcast_ref::().unwrap(); - let label = item.child().and_downcast::().unwrap(); - let obj = item.item().and_downcast::().unwrap(); - let entry = obj.borrow::(); - label.set_label(&render(&entry)); - }); - gtk4::ColumnViewColumn::new(Some(title), Some(factory)) -} - -/// Attach a secondary-button [`gtk4::GestureClick`] to a cell that pops a context -/// menu for whatever entry the owning `item` is currently bound to. Capturing the -/// [`gtk4::ListItem`] (rather than a snapshot of the entry) keeps the menu correct -/// as the view recycles cells while scrolling. -fn attach_context_menu(ui: &Rc, item: >k4::ListItem, anchor: >k4::Box) { - let gesture = gtk4::GestureClick::new(); - gesture.set_button(gtk4::gdk::BUTTON_SECONDARY); - let ui = ui.clone(); - let item = item.clone(); - let target = anchor.clone(); - gesture.connect_pressed(move |_, _, x, y| { - if let Some(obj) = item.item().and_downcast::() { - let entry = obj.borrow::().clone(); - show_context_menu(&ui, &entry, &target, x, y); - } - }); - anchor.add_controller(gesture); -} - -/// Pop a context menu next to `anchor` at the click point, offering Open and a -/// Pin/Unpin toggle (files only). Built fresh per click because the items are -/// entry-specific; it unparents itself once dismissed. -fn show_context_menu(ui: &Rc, entry: &DirEntry, anchor: >k4::Box, x: f64, y: f64) { - let menu = gtk4::Box::new(gtk4::Orientation::Vertical, 0); - let popover = gtk4::Popover::builder() - .has_arrow(false) - .position(gtk4::PositionType::Bottom) - .pointing_to(>k4::gdk::Rectangle::new(x as i32, y as i32, 1, 1)) - .child(&menu) - .build(); - popover.set_parent(anchor); - popover.connect_closed(|p| p.unparent()); - - // Videos lead with Play (stream from the mount, no download); the plain Open - // below still downloads a local copy for anyone who wants one. - if is_video_entry(entry) { - let play = menu_item("Play (stream)", "media-playback-start-symbolic"); - let ui_play = ui.clone(); - let entry_play = entry.clone(); - let pop = popover.clone(); - play.connect_clicked(move |_| { - pop.popdown(); - stream_entry(&ui_play, &entry_play); - }); - menu.append(&play); - } - - let open = menu_item("Open", "document-open-symbolic"); - let ui_open = ui.clone(); - let entry_open = entry.clone(); - let pop = popover.clone(); - open.connect_clicked(move |_| { - pop.popdown(); - // Open always means "download a local copy and hand off", even for a - // video — `activate_entry` would otherwise stream it. - if is_video_entry(&entry_open) { - download_and_open(&ui_open, &entry_open); - } else { - activate_entry(&ui_open, &entry_open); - } - }); - menu.append(&open); - - if !entry.is_dir { - let (label, icon) = if entry.pinned { - ("Unpin", "non-starred-symbolic") - } else { - ("Keep offline", "starred-symbolic") - }; - let pin = menu_item(label, icon); - let ui_pin = ui.clone(); - let entry_pin = entry.clone(); - let pop = popover.clone(); - pin.connect_clicked(move |_| { - pop.popdown(); - toggle_pin(&ui_pin, &entry_pin); - }); - 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); - - menu.append(>k4::Separator::new(gtk4::Orientation::Horizontal)); - - let share = menu_item("Share…", "emblem-shared-symbolic"); - let ui_sh = ui.clone(); - let entry_sh = entry.clone(); - let pop = popover.clone(); - share.connect_clicked(move |_| { - pop.popdown(); - open_share_dialog(&ui_sh, &entry_sh); - }); - menu.append(&share); - - 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(); -} - -/// A left-aligned, flat icon+label button for the context menu. -fn menu_item(label: &str, icon: &str) -> gtk4::Button { - let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); - row.append(>k4::Image::from_icon_name(icon)); - row.append( - >k4::Label::builder() - .label(label) - .halign(gtk4::Align::Start) - .hexpand(true) - .build(), - ); - let button = gtk4::Button::builder().child(&row).build(); - button.add_css_class("flat"); - button -} - -/// Fetch the [`DirEntry`] backing the model item at `pos`, if any. -fn entry_at(model: Option<&impl IsA>, pos: u32) -> Option { - let obj = model?.item(pos).and_downcast::()?; - let entry = obj.borrow::().clone(); - Some(entry) -} - -/// Whether an entry's name marks it as a video, reusing the same classifier the -/// Photos page splits on — so the file browser and the gallery agree on what a -/// video is. -fn is_video_entry(entry: &DirEntry) -> bool { - !entry.is_dir && PhotoKind::classify(Some(&entry.name), None) == PhotoKind::Video -} - -/// Stream a video straight from the mount, no download. Drive folders *are* part -/// of the FUSE mount, so a player pointed at `/` reads the file -/// through [`Core::read_range_remote`] — 4 MB blocks fetched on demand as it -/// seeks and buffers — instead of waiting for the whole file to land like -/// [`Request::OpenFile`] does. This is the point of the feature: a 2 GB HEVC -/// `.mkv` starts playing in seconds. -fn stream_entry(ui: &Rc, entry: &DirEntry) { - let rel = entry_rel(ui, entry); - let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config()); - let abs = mountpoint.join(&rel); - let Some(path) = abs.to_str() else { - toast_error(ui, "Couldn't play video", "The file path isn't valid UTF-8."); - return; - }; - toast(ui, &format!("Streaming “{}”…", entry.name)); - play_external(path); -} - -/// Open an entry the Nautilus way: folders descend, videos stream from the mount, -/// other files download-and-open. -fn activate_entry(ui: &Rc, entry: &DirEntry) { - let rel = entry_rel(ui, entry); - if entry.is_dir { - // Descending into a search hit: clear the query so the folder listing - // isn't immediately re-masked by a stale search. - if !entry.path.is_empty() { - ui.browser_search.set_text(""); - } - *ui.browser_path.borrow_mut() = rel; - load_browser(ui); - } else if is_video_entry(entry) { - // A video streams rather than downloads: that is exactly the "play it, - // don't fetch the whole thing" behaviour this is for. - stream_entry(ui, entry); - } else { - download_and_open(ui, entry); - } -} - -/// Download a file's full content into the cache and hand it to the user's -/// default application. The download-and-open path behind both a plain -/// double-click and the context menu's "Open" (including for a video, when the -/// user explicitly wants a local copy rather than to stream it). -fn download_and_open(ui: &Rc, entry: &DirEntry) { - let rel = entry_rel(ui, entry); - // Ignore a repeat activation of a file already downloading, so an impatient - // double-click doesn't kick off a second round-trip. - if !ui.opening.borrow_mut().insert(rel.clone()) { - return; - } - ui.busy_begin(); - let rx = spawn_request( - ui.dirs.control_socket(), - Request::OpenFile { path: rel.clone() }, - ); - let ui = ui.clone(); - glib::spawn_future_local(async move { - let result = rx.recv().await; - ui.busy_end(); - ui.opening.borrow_mut().remove(&rel); - match result { - Ok(Ok(Response::FilePath { path })) => open_path(&path), - Ok(Ok(Response::Error { message })) => { - toast_error(&ui, "Couldn't open file", &message) - } - _ => toast_error( - &ui, - "Couldn't open file", - "The mount service didn't respond.", - ), - } - }); -} - -/// Pin or unpin an entry through the daemon, then reload to reflect the new -/// state. -fn toggle_pin(ui: &Rc, entry: &DirEntry) { - let rel = entry_rel(ui, entry); - let req = if entry.pinned { - Request::Unpin { path: rel } - } else { - Request::Pin { path: rel } - }; - let rx = spawn_request(ui.dirs.control_socket(), req); - let name = entry.name.clone(); - let pinned = entry.pinned; - let ui = ui.clone(); - glib::spawn_future_local(async move { - match rx.recv().await { - Ok(Ok(Response::Error { message })) => { - toast_error(&ui, "Couldn't change offline state", &message); - // The details switch may be showing the state we failed to reach. - load_browser(&ui); - } - Ok(Ok(_)) => { - load_browser(&ui); - toast( - &ui, - &if pinned { - format!("“{name}” is no longer kept offline") - } else { - format!("“{name}” is now available offline") - }, - ); - } - _ => { - toast_error( - &ui, - "Couldn't change offline state", - "The mount service didn't respond.", - ); - load_browser(&ui); - } - } - }); -} - -/// Join the entry name onto the current browser directory to get its -/// mountpoint-relative path. -fn entry_rel(ui: &Rc, entry: &DirEntry) -> String { - // Search hits carry an absolute (mountpoint-relative) path since they can - // live anywhere; plain listing entries derive it from the current folder. - if !entry.path.is_empty() { - return entry.path.clone(); - } - let base = ui.browser_path.borrow(); - if base.is_empty() { - entry.name.clone() - } else { - format!("{base}/{}", entry.name) - } -} - -/// 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, Upload-files and Upload-folder buttons. -fn wire_browser_actions( - ui: &Rc, - new_folder: >k4::Button, - upload: >k4::Button, - upload_folder: >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)); - let ui_uf = ui.clone(); - upload_folder.connect_clicked(move |_| prompt_upload_folder(&ui_uf)); -} - -/// Send a mutating request (rename / move / delete / mkdir / upload, or a trash -/// restore / purge) on a worker thread, then reload the listing it changed and -/// confirm with a toast, or report the daemon's error in one. `done` is the -/// past-tense confirmation ("Renamed to “x”"); `failed` names the attempt -/// ("Couldn't rename"). -fn run_mutation(ui: &Rc, req: Request, done: String, failed: &'static str) { - if !*ui.mounted.borrow() { - toast_error(ui, failed, "Proton Drive isn't connected."); - return; - } - 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 { .. })) => { - // The listing the mutation changed is stale now; reload it, then - // confirm, so the toast lands over the updated view. - reload_listing(&ui); - toast(&ui, &done); - } - Ok(Ok(Response::Error { message })) => toast_error(&ui, failed, &message), - _ => toast_error(&ui, failed, "The mount service didn't respond."), - } - }); -} - -/// Reload the listing a completed mutation invalidated: whichever of the two -/// listing pages is on screen, since that is the one the action was raised from. -fn reload_listing(ui: &Rc) { - match ui.stack.visible_child_name().as_deref() { - Some("browser") => load_browser(ui), - Some("trash") => load_trash(ui), - _ => {} - } -} - -/// 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") - .activates_default(true) - .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; - } - let done = format!("Renamed to “{new_name}”"); - run_mutation( - &ui, - Request::Rename { - path: rel.clone(), - new_name, - }, - done, - "Couldn't rename", - ); - }); - 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 name = entry.name.clone(); - 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") - .activates_default(true) - .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(); - let done = match new_parent.as_str() { - "" => format!("Moved “{name}” to Proton Drive"), - dest => format!("Moved “{name}” to “{dest}”"), - }; - run_mutation( - &ui, - Request::Move { - path: rel.clone(), - new_parent, - }, - done, - "Couldn't move", - ); - }); - 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 name = entry.name.clone(); - 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() }, - format!("Moved “{name}” to Trash"), - "Couldn't move to Trash", - ); - } - }); - 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") - .activates_default(true) - .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; - } - let done = format!("Created “{name}”"); - run_mutation( - &ui, - Request::CreateFolder { - parent: parent.clone(), - name, - }, - done, - "Couldn't create folder", - ); - }); - dialog.present(win.as_ref()); -} - -/// Pick one or more local files and upload them into the current browser -/// directory. The daemon streams them from disk itself, so nothing is read into -/// the GUI — even a large multi-file selection. -fn prompt_upload(ui: &Rc) { - let win = ui_window(ui); - let dialog = gtk4::FileDialog::builder().title("Upload Files").build(); - let ui = ui.clone(); - dialog.open_multiple(win.as_ref(), gio::Cancellable::NONE, move |res| { - let Ok(files) = res else { return }; - let sources: Vec = files - .into_iter() - .filter_map(|f| f.ok()) - .filter_map(|obj| obj.downcast::().ok()) - .filter_map(|f| f.path()) - .filter_map(|p| p.to_str().map(str::to_string)) - .collect(); - start_upload(&ui, sources); - }); -} - -/// Pick a local folder and upload it — with its whole subtree — into the current -/// browser directory. The daemon recreates the directory structure remotely. -fn prompt_upload_folder(ui: &Rc) { - let win = ui_window(ui); - let dialog = gtk4::FileDialog::builder().title("Upload 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().and_then(|p| p.to_str().map(str::to_string)) else { - return; - }; - start_upload(&ui, vec![path]); - }); -} - -/// Hand a set of local source paths to the daemon for background bulk upload. -/// The daemon acks at once and does the work off-socket, so we confirm the -/// hand-off with a toast; the Activity group then shows live progress and the -/// listing refreshes itself when the transfers finish (see [`repaint_transfers`]). -fn start_upload(ui: &Rc, sources: Vec) { - if sources.is_empty() { - return; - } - if !*ui.mounted.borrow() { - toast_error(ui, "Couldn't upload", "Proton Drive isn't connected."); - return; - } - let parent = ui.browser_path.borrow().clone(); - let n = sources.len(); - let rx = spawn_request( - ui.dirs.control_socket(), - Request::UploadPaths { parent, sources }, - ); - let ui = ui.clone(); - glib::spawn_future_local(async move { - match rx.recv().await { - Ok(Ok(Response::Ok { .. })) => { - let what = if n == 1 { - "Uploading…".to_string() - } else { - format!("Uploading {n} items…") - }; - toast(&ui, &what); - } - Ok(Ok(Response::Error { message })) => toast_error(&ui, "Couldn't upload", &message), - _ => toast_error(&ui, "Couldn't upload", "The mount service didn't respond."), - } - }); -} - -/// The top-level window, for parenting dialogs. -fn ui_window(ui: &Rc) -> Option { - ui.stack.root().and_downcast::() -} - -/// 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; - } - let done = format!("Moved into “{}”", dest.name); - run_mutation( - &ui, - Request::Move { - path: src, - new_parent: dest_path, - }, - done, - "Couldn't move", - ); - true - }); - anchor.add_controller(target); -} - -/// Pick a freedesktop icon base name for an entry from its kind / extension. -/// Callers append `-symbolic` for the column view's small icons. -fn icon_base_for(entry: &DirEntry) -> &'static str { - if entry.is_dir { - return "folder"; - } - let ext = entry - .name - .rsplit('.') - .next() - .unwrap_or("") - .to_ascii_lowercase(); - match ext.as_str() { - "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "svg" | "heic" | "tiff" => { - "image-x-generic" - } - "mp4" | "mkv" | "mov" | "avi" | "webm" | "m4v" => "video-x-generic", - "mp3" | "flac" | "wav" | "ogg" | "opus" | "m4a" => "audio-x-generic", - "pdf" | "doc" | "docx" | "odt" => "x-office-document", - "xls" | "xlsx" | "ods" | "csv" => "x-office-spreadsheet", - "ppt" | "pptx" | "odp" => "x-office-presentation", - "zip" | "tar" | "gz" | "xz" | "bz2" | "7z" | "rar" => "package-x-generic", - _ => "text-x-generic", - } -} - -/// Format an epoch-seconds modification time as a short local date. -fn format_modified(secs: i64) -> String { - match glib::DateTime::from_unix_local(secs) { - Ok(dt) => dt - .format("%-d %b %Y") - .map(|s| s.to_string()) - .unwrap_or_default(), - Err(_) => String::new(), - } -} - -/// Request the current browser directory from the daemon and repaint both views. -fn load_browser(ui: &Rc) { - let path = ui.browser_path.borrow().clone(); - 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 - // entries visible, where clicking one would open with a wrong relative path. - ui.browser_model.remove_all(); - browser_status( - ui, - "folder-symbolic", - "Loading…", - "Reading this folder.", - false, - ); - - ui.busy_begin(); - let rx = spawn_request( - ui.dirs.control_socket(), - Request::ListDir { path: path.clone() }, - ); - let ui = ui.clone(); - glib::spawn_future_local(async move { - let result = rx.recv().await; - ui.busy_end(); - // The user may have navigated on while this folder was loading. A stale - // out-of-order reply must not repaint rows for a folder we've left, or - // the breadcrumb and the grid would disagree. - if *ui.browser_path.borrow() != path { - return; - } - match result { - Ok(Ok(Response::Entries { entries })) => repaint_browser(&ui, &entries), - Ok(Ok(Response::Error { message })) => browser_failed(&ui, &message), - Ok(Ok(_)) => browser_failed(&ui, "Unexpected reply from the mount service."), - Ok(Err(_)) | Err(_) => browser_unreachable(&ui), - } - }); -} - -/// Clear the model and show the daemon's error on the status page. Used for -/// in-band failures (a bad path, a permission error) — the mount is up, so Retry -/// (which restarts the service) wouldn't help and isn't offered. -fn browser_failed(ui: &Rc, message: &str) { - ui.browser_model.remove_all(); - browser_status( - ui, - "dialog-warning-symbolic", - "Couldn't open this folder", - message, - false, - ); -} - -/// The daemon didn't answer. Distinguish *still starting* (auto-retry, no -/// button) from *down* (actionable error + Retry), so a cold start self-heals -/// once the systemd mount comes up but a real failure stays visible. -fn browser_unreachable(ui: &Rc) { - if service::is_failed() || !service::is_active() { - browser_status( - ui, - "network-offline-symbolic", - "Not connected", - "The Proton Drive mount service isn't running.", - true, - ); - return; - } - browser_status( - ui, - "folder-remote-symbolic", - "Connecting…", - "Waiting for the Proton Drive mount service to come up.", - false, - ); - let ui = ui.clone(); - glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || { - // Only keep polling while the Files page is the one on screen. - if ui.stack.visible_child_name().as_deref() == Some("browser") { - load_browser(&ui); - } - }); -} - -/// Repopulate the shared model — folders first, then case-insensitive by name — -/// which refreshes both the grid and the column list. -fn repaint_browser(ui: &Rc, entries: &[DirEntry]) { - ui.browser_model.remove_all(); - if entries.is_empty() { - browser_status( - ui, - "folder-open-symbolic", - "This folder is empty", - "Upload a file or create a folder to get started.", - false, - ); - return; - } - browser_views(ui); - - let mut sorted = entries.to_vec(); - sorted.sort_by(|a, b| { - b.is_dir - .cmp(&a.is_dir) - .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) - }); - for entry in sorted { - ui.browser_model.append(&BoxedAnyObject::new(entry)); - } -} - -/// Wire the browser header's search box: debounce keystrokes, then either run a -/// search or — when cleared — restore the current directory listing. -fn wire_search(ui: &Rc) { - let ui_s = ui.clone(); - ui.browser_search.connect_search_changed(move |_| { - // Replace any pending debounce so only the last keystroke's pause fires. - if let Some(src) = ui_s.search_source.borrow_mut().take() { - src.remove(); - } - let ui_t = ui_s.clone(); - let src = glib::timeout_add_local_once(SEARCH_DEBOUNCE, move || { - ui_t.search_source.borrow_mut().take(); - let query = ui_t.browser_search.text().trim().to_string(); - if query.is_empty() { - load_browser(&ui_t); - } else { - run_search(&ui_t, &query); - } - }); - *ui_s.search_source.borrow_mut() = Some(src); - }); -} - -/// Send a [`Request::Search`] to the daemon and render the hits in the browser -/// views, reusing the same row model so click-to-open and pin work unchanged -/// (each hit carries its full path; see [`entry_rel`]). -fn run_search(ui: &Rc, query: &str) { - ui.browser_model.remove_all(); - browser_status( - ui, - "system-search-symbolic", - "Searching…", - &format!("Looking for “{query}”."), - false, - ); - - ui.busy_begin(); - let query = query.to_string(); - let rx = spawn_request( - ui.dirs.control_socket(), - Request::Search { - query: query.clone(), - limit: SEARCH_LIMIT, - }, - ); - let ui = ui.clone(); - glib::spawn_future_local(async move { - let result = rx.recv().await; - ui.busy_end(); - // The box may have been cleared or typed past while the reply was in - // flight; if the query no longer matches, a fresher load/search already - // owns the model — drop this stale, possibly out-of-order result. - if ui.browser_search.text().trim() != query { - return; - } - match result { - Ok(Ok(Response::SearchResults { hits })) => repaint_search(&ui, &hits), - Ok(Ok(Response::Error { message })) => browser_failed(&ui, &message), - Ok(Ok(_)) => browser_failed(&ui, "Unexpected reply from the mount service."), - Ok(Err(_)) | Err(_) => browser_unreachable(&ui), - } - }); -} - -/// Repopulate the model with search hits — folders first, then by name — mapping -/// each [`SearchHit`] to a path-carrying [`DirEntry`] the existing renderers and -/// handlers already understand. -fn repaint_search(ui: &Rc, hits: &[SearchHit]) { - ui.browser_model.remove_all(); - if hits.is_empty() { - browser_status( - ui, - "system-search-symbolic", - "No matches", - "No files or folders match that search.", - false, - ); - return; - } - browser_views(ui); - - let mut entries: Vec = hits - .iter() - .map(|h| DirEntry { - name: h.name.clone(), - is_dir: h.is_dir, - 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(), - }) - .collect(); - entries.sort_by(|a, b| { - b.is_dir - .cmp(&a.is_dir) - .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) - }); - for entry in entries { - ui.browser_model.append(&BoxedAnyObject::new(entry)); - } -} - -/// The widgets of the Trash page that a load repaints. -struct TrashWidgets { - model: gio::ListStore, - list: gtk4::ListView, - content: gtk4::Stack, - status: adw::StatusPage, - retry: gtk4::Button, - empty: gtk4::Button, - refresh: gtk4::Button, - subtitle: gtk4::Label, -} - -/// Widgets the Shared page's load/repaint touch. -struct SharedWidgets { - content: gtk4::Stack, - status: adw::StatusPage, - shared_with_me: adw::PreferencesGroup, - invitations: adw::PreferencesGroup, - bookmarks: adw::PreferencesGroup, - retry: gtk4::Button, - add_bookmark: gtk4::Button, - refresh: gtk4::Button, -} - -/// Widgets the Devices page's load/repaint touch. -struct DevicesWidgets { - content: gtk4::Stack, - status: adw::StatusPage, - /// "This computer" — the local folders synced to this machine's device. - sync_group: adw::PreferencesGroup, - /// "Other computers" — the account's *other* registered devices. This - /// machine's own device is deliberately not among them; see - /// [`repaint_devices`]. - group: adw::PreferencesGroup, - add_folder: gtk4::Button, - retry: gtk4::Button, - refresh: gtk4::Button, -} - -/// The Shared page: three stacked sections — items shared *with* me (each with a -/// Leave action), invitations addressed to me pending accept/reject, and public -/// links I've bookmarked (open / remove). All three live outside the mount tree, -/// so the page addresses them by uid/id/token and always re-lists from the daemon. -fn build_shared_page() -> (gtk4::Widget, SharedWidgets) { - let title = gtk4::Label::builder() - .label("Shared with me") - .halign(gtk4::Align::Start) - .build(); - title.add_css_class("title-2"); - let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 2); - titles.set_hexpand(true); - titles.append(&title); - - let add_bookmark = gtk4::Button::builder() - .label("Add Bookmark") - .valign(gtk4::Align::Center) - .build(); - add_bookmark.add_css_class("flat"); - let refresh = refresh_button(); - - let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); - header.append(&titles); - header.append(&refresh); - header.append(&add_bookmark); - - let shared_with_me = adw::PreferencesGroup::builder() - .title("Shared with me") - .build(); - let invitations = adw::PreferencesGroup::builder() - .title("Invitations") - .build(); - let bookmarks = adw::PreferencesGroup::builder().title("Bookmarks").build(); - - let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18); - groups.append(&shared_with_me); - groups.append(&invitations); - groups.append(&bookmarks); - let clamp = adw::Clamp::builder().child(&groups).build(); - let scroll = gtk4::ScrolledWindow::builder() - .vexpand(true) - .child(&clamp) - .build(); - - let retry = gtk4::Button::builder() - .label("Retry") - .halign(gtk4::Align::Center) - .build(); - retry.add_css_class("pill"); - retry.add_css_class("suggested-action"); - retry.set_visible(false); - let status = adw::StatusPage::builder() - .icon_name("emblem-shared-symbolic") - .vexpand(true) - .child(&retry) - .build(); - status.add_css_class("compact"); - - let content = gtk4::Stack::new(); - content.set_vexpand(true); - content.set_transition_type(gtk4::StackTransitionType::Crossfade); - content.add_named(&scroll, Some("list")); - content.add_named(&status, Some("status")); - - let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12); - inner.set_margin_top(12); - inner.set_margin_bottom(12); - inner.set_margin_start(12); - inner.set_margin_end(12); - inner.append(&header); - inner.append(&content); - - ( - inner.upcast(), - SharedWidgets { - content, - status, - shared_with_me, - invitations, - bookmarks, - retry, - add_bookmark, - refresh, - }, - ) -} - -/// The Computers page: a "This computer" section listing the local folders synced -/// to this machine's device (each row offering a mode toggle and Remove), plus an -/// "Other devices" section listing the account's other registered devices. -fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) { - let title = gtk4::Label::builder() - .label("Computers") - .halign(gtk4::Align::Start) - .build(); - title.add_css_class("title-2"); - let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 2); - titles.set_hexpand(true); - titles.append(&title); - - let add_folder = gtk4::Button::builder() - .label("Add Folder") - .valign(gtk4::Align::Center) - .build(); - add_folder.add_css_class("flat"); - let refresh = refresh_button(); - - let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); - header.append(&titles); - header.append(&refresh); - header.append(&add_folder); - - // The description spells out what the per-row On-demand switch does to the - // local copy, because the switch itself can't: turning it on *deletes* the - // files from this disk, which is not something to discover afterwards. - let sync_group = adw::PreferencesGroup::builder() - .title("This computer") - .description( - "Local folders backed up to this device. Synced folders keep a full copy on \ - this disk; on-demand folders keep the files in Proton Drive only and fetch \ - them as you open them.", - ) - .build(); - let group = adw::PreferencesGroup::builder() - .title("Other computers") - .description("Other devices backing up to this account.") - .build(); - - let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18); - groups.append(&sync_group); - groups.append(&group); - let clamp = adw::Clamp::builder().child(&groups).build(); - let scroll = gtk4::ScrolledWindow::builder() - .vexpand(true) - .child(&clamp) - .build(); - - let retry = gtk4::Button::builder() - .label("Retry") - .halign(gtk4::Align::Center) - .build(); - retry.add_css_class("pill"); - retry.add_css_class("suggested-action"); - retry.set_visible(false); - let status = adw::StatusPage::builder() - .icon_name("computer-symbolic") - .vexpand(true) - .child(&retry) - .build(); - status.add_css_class("compact"); - - let content = gtk4::Stack::new(); - content.set_vexpand(true); - content.set_transition_type(gtk4::StackTransitionType::Crossfade); - content.add_named(&scroll, Some("list")); - content.add_named(&status, Some("status")); - - let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12); - inner.set_margin_top(12); - inner.set_margin_bottom(12); - inner.set_margin_start(12); - inner.set_margin_end(12); - inner.append(&header); - inner.append(&content); - - ( - inner.upcast(), - DevicesWidgets { - content, - status, - sync_group, - group, - add_folder, - retry, - refresh, - }, - ) -} - -/// The Trash page: a flat list of everything Drive is holding in the trash, each -/// row offering Restore and Delete Forever, with Empty Trash in the header. -/// -/// A trashed node has no path inside the mount — the daemon forgets it when it is -/// trashed — so unlike the Files page this one addresses entries by uid and always -/// re-lists from the server rather than from a cached listing. Row rendering needs -/// the [`Ui`] handle for its buttons, so the factory is installed in [`wire_trash`]. -fn build_trash_page() -> (gtk4::Widget, TrashWidgets) { - let model = gio::ListStore::new::(); - - let title = gtk4::Label::builder() - .label("Trash") - .halign(gtk4::Align::Start) - .build(); - title.add_css_class("title-2"); - let subtitle = gtk4::Label::builder().halign(gtk4::Align::Start).build(); - subtitle.add_css_class("dim-label"); - let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 2); - titles.set_hexpand(true); - titles.append(&title); - titles.append(&subtitle); - - let empty = gtk4::Button::builder() - .label("Empty Trash") - .valign(gtk4::Align::Center) - .sensitive(false) - .build(); - empty.add_css_class("destructive-action"); - let refresh = refresh_button(); - - let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); - header.append(&titles); - header.append(&refresh); - header.append(&empty); - - let retry = gtk4::Button::builder() - .label("Retry") - .halign(gtk4::Align::Center) - .build(); - retry.add_css_class("pill"); - retry.add_css_class("suggested-action"); - retry.set_visible(false); - let status = adw::StatusPage::builder() - .icon_name("user-trash-symbolic") - .vexpand(true) - .child(&retry) - .build(); - status.add_css_class("compact"); - - // No selection model: every action lives on the row it acts on, so a - // mis-aimed Delete Forever isn't one click away from a stale selection. - let list = gtk4::ListView::builder() - .model(>k4::NoSelection::new(Some(model.clone()))) - .build(); - let scroll = gtk4::ScrolledWindow::builder() - .vexpand(true) - .child(&list) - .build(); - - let content = gtk4::Stack::new(); - content.set_vexpand(true); - content.set_transition_type(gtk4::StackTransitionType::Crossfade); - content.add_named(&scroll, Some("list")); - content.add_named(&status, Some("status")); - - let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12); - inner.set_margin_top(12); - inner.set_margin_bottom(12); - inner.set_margin_start(12); - inner.set_margin_end(12); - inner.append(&header); - inner.append(&content); - - ( - inner.upcast(), - TrashWidgets { - model, - list, - content, - status, - retry, - empty, - refresh, - subtitle, - }, - ) -} - -/// Install the row factory and the Empty Trash button. The row's two buttons read -/// the entry off the [`gtk4::ListItem`] they were clicked on rather than a -/// captured copy, so a recycled row always acts on the item it currently shows. -fn wire_trash(ui: &Rc, list: >k4::ListView, empty: >k4::Button) { - let factory = gtk4::SignalListItemFactory::new(); - let ui_setup = ui.clone(); - factory.connect_setup(move |_, item| { - let item = item.downcast_ref::().unwrap(); - - let icon = gtk4::Image::builder().pixel_size(32).build(); - let name = gtk4::Label::builder() - .halign(gtk4::Align::Start) - .ellipsize(gtk4::pango::EllipsizeMode::Middle) - .build(); - let meta = gtk4::Label::builder().halign(gtk4::Align::Start).build(); - meta.add_css_class("dim-label"); - meta.add_css_class("caption"); - let text = gtk4::Box::new(gtk4::Orientation::Vertical, 2); - text.set_hexpand(true); - text.set_valign(gtk4::Align::Center); - text.append(&name); - text.append(&meta); - - let restore = gtk4::Button::builder() - .icon_name("edit-undo-symbolic") - .tooltip_text("Restore to its original folder") - .valign(gtk4::Align::Center) - .build(); - restore.add_css_class("flat"); - let purge = gtk4::Button::builder() - .icon_name("edit-delete-symbolic") - .tooltip_text("Delete permanently") - .valign(gtk4::Align::Center) - .build(); - purge.add_css_class("flat"); - - let ui_restore = ui_setup.clone(); - let item_restore = item.clone(); - restore.connect_clicked(move |_| { - if let Some(entry) = bound_entry(&item_restore) { - restore_entry(&ui_restore, &entry); - } - }); - let ui_purge = ui_setup.clone(); - let item_purge = item.clone(); - purge.connect_clicked(move |_| { - if let Some(entry) = bound_entry(&item_purge) { - prompt_delete_forever(&ui_purge, &entry); - } - }); - - let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); - row.set_margin_top(6); - row.set_margin_bottom(6); - row.set_margin_start(6); - row.set_margin_end(6); - row.append(&icon); - row.append(&text); - row.append(&restore); - row.append(&purge); - item.set_child(Some(&row)); - }); - factory.connect_bind(|_, item| { - let item = item.downcast_ref::().unwrap(); - let Some(entry) = bound_entry(item) else { - return; - }; - let row = item.child().and_downcast::().unwrap(); - let Some(icon) = row.first_child().and_downcast::() else { - return; - }; - icon.set_icon_name(Some(&format!("{}-symbolic", icon_base_for(&entry)))); - let Some(text) = icon.next_sibling().and_downcast::() else { - return; - }; - if let Some(name) = text.first_child().and_downcast::() { - name.set_label(&entry.name); - } - if let Some(meta) = text.last_child().and_downcast::() { - let kind = if entry.is_dir { - "Folder".to_string() - } else { - human_bytes(entry.size) - }; - meta.set_label(&format!("{kind} · {}", format_modified(entry.modified))); - } - }); - list.set_factory(Some(&factory)); - - let ui_empty = ui.clone(); - empty.connect_clicked(move |_| prompt_empty_trash(&ui_empty)); -} - -/// The [`DirEntry`] a list item is currently bound to, or `None` for an unbound -/// (recycled) row. -fn bound_entry(item: >k4::ListItem) -> Option { - let obj = item.item().and_downcast::()?; - let entry = obj.borrow::().clone(); - Some(entry) -} - -/// Fetch the trash listing and repaint the page. -fn load_trash(ui: &Rc) { - // Drop the old rows first: a stale row here would offer Restore on something - // that may already be gone. - ui.trash_model.remove_all(); - ui.trash_empty.set_sensitive(false); - trash_status( - ui, - "user-trash-symbolic", - "Loading…", - "Reading the trash.", - false, - ); - - ui.busy_begin(); - let rx = spawn_request(ui.dirs.control_socket(), Request::ListTrash); - let ui = ui.clone(); - glib::spawn_future_local(async move { - let result = rx.recv().await; - ui.busy_end(); - match result { - Ok(Ok(Response::Entries { entries })) => repaint_trash(&ui, &entries), - Ok(Ok(Response::Error { message })) => trash_status( - &ui, - "dialog-warning-symbolic", - "Couldn't read the trash", - &message, - false, - ), - Ok(Ok(_)) => trash_status( - &ui, - "dialog-warning-symbolic", - "Couldn't read the trash", - "Unexpected reply from the mount service.", - false, - ), - Ok(Err(_)) | Err(_) => trash_unreachable(&ui), - } - }); -} - -/// The daemon didn't answer. Same split as the Files page: still starting (poll -/// again, no button) versus actually down (Retry, which restarts the service). -fn trash_unreachable(ui: &Rc) { - if service::is_failed() || !service::is_active() { - trash_status( - ui, - "network-offline-symbolic", - "Not connected", - "The Proton Drive mount service isn't running.", - true, - ); - return; - } - trash_status( - ui, - "folder-remote-symbolic", - "Connecting…", - "Waiting for the Proton Drive mount service to come up.", - false, - ); - let ui = ui.clone(); - glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || { - if ui.stack.visible_child_name().as_deref() == Some("trash") { - load_trash(&ui); - } - }); -} - -/// Show a status page in place of the trash list. -fn trash_status(ui: &Rc, icon: &str, title: &str, description: &str, retry: bool) { - ui.trash_status.set_icon_name(Some(icon)); - ui.trash_status.set_title(title); - ui.trash_status.set_description(Some(description)); - ui.trash_retry.set_visible(retry); - ui.trash_content.set_visible_child_name("status"); - ui.trash_subtitle.set_label(""); -} - -/// Repopulate the trash list, most recently modified first — the order in which a -/// user looks for what they just deleted. -fn repaint_trash(ui: &Rc, entries: &[DirEntry]) { - ui.trash_model.remove_all(); - ui.trash_empty.set_sensitive(!entries.is_empty()); - if entries.is_empty() { - trash_status( - ui, - "user-trash-symbolic", - "Trash is empty", - "Items you delete from Proton Drive show up here.", - false, - ); - return; - } - ui.trash_content.set_visible_child_name("list"); - ui.trash_subtitle.set_label(&match entries.len() { - 1 => "1 item".to_string(), - n => format!("{n} items"), - }); - - let mut sorted = entries.to_vec(); - sorted.sort_by_key(|e| std::cmp::Reverse(e.modified)); - for entry in sorted { - ui.trash_model.append(&BoxedAnyObject::new(entry)); - } -} - -/// Restore one trashed entry to the folder it was trashed from. -fn restore_entry(ui: &Rc, entry: &DirEntry) { - let name = entry.name.clone(); - run_mutation( - ui, - Request::Restore { - uids: vec![entry.uid.clone()], - }, - format!("Restored “{name}”"), - "Couldn't restore", - ); -} - -/// Confirm, then permanently delete one trashed entry. Irreversible, so it asks. -fn prompt_delete_forever(ui: &Rc, entry: &DirEntry) { - let win = ui_window(ui); - let uid = entry.uid.clone(); - let name = entry.name.clone(); - let dialog = adw::AlertDialog::builder() - .heading("Delete permanently") - .body(format!( - "Permanently delete “{name}”? This cannot be undone." - )) - .build(); - dialog.add_response("cancel", "Cancel"); - dialog.add_response("delete", "Delete Permanently"); - dialog.set_response_appearance("delete", 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 == "delete" { - run_mutation( - &ui, - Request::DeleteForever { - uids: vec![uid.clone()], - }, - format!("Deleted “{name}” permanently"), - "Couldn't delete", - ); - } - }); - dialog.present(win.as_ref()); -} - -/// Confirm, then permanently delete everything in the trash. -fn prompt_empty_trash(ui: &Rc) { - let win = ui_window(ui); - let count = ui.trash_model.n_items(); - let dialog = adw::AlertDialog::builder() - .heading("Empty Trash") - .body(format!( - "Permanently delete all {count} item(s) in the trash? This cannot be undone." - )) - .build(); - dialog.add_response("cancel", "Cancel"); - dialog.add_response("empty", "Empty Trash"); - dialog.set_response_appearance("empty", 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 == "empty" { - run_mutation( - &ui, - Request::EmptyTrash, - "Trash emptied".to_string(), - "Couldn't empty the trash", - ); - } - }); - dialog.present(win.as_ref()); -} - -// ---- Shared page ---------------------------------------------------------- - -/// Install the Shared page's retry and Add-Bookmark buttons. -fn wire_shared(ui: &Rc, retry: >k4::Button, add_bookmark: >k4::Button) { - let ui_retry = ui.clone(); - retry.connect_clicked(move |_| { - service::restart(); - load_shared(&ui_retry); - }); - let ui_add = ui.clone(); - add_bookmark.connect_clicked(move |_| prompt_add_bookmark(&ui_add)); -} - -/// Show a status page in place of the Shared sections. -fn shared_status(ui: &Rc, icon: &str, title: &str, description: &str, retry: bool) { - ui.shared_status.set_icon_name(Some(icon)); - ui.shared_status.set_title(title); - ui.shared_status.set_description(Some(description)); - ui.shared_retry.set_visible(retry); - ui.shared_content.set_visible_child_name("status"); -} - -/// Fetch the three Shared sections (shared-with-me, invitations, bookmarks) in -/// parallel and repaint the page once all three land. -fn load_shared(ui: &Rc) { - if ui.shared_inflight.get() { - return; - } - ui.shared_inflight.set(true); - shared_status( - ui, - "emblem-shared-symbolic", - "Loading…", - "Reading your shared items.", - false, - ); - - ui.busy_begin(); - let socket = ui.dirs.control_socket(); - let shared_rx = spawn_request(socket.clone(), Request::ListSharedWithMe); - let invites_rx = spawn_request(socket.clone(), Request::ListInvitations); - let bookmarks_rx = spawn_request(socket, Request::ListBookmarks); - let ui = ui.clone(); - glib::spawn_future_local(async move { - let shared = shared_rx.recv().await; - let invites = invites_rx.recv().await; - let bookmarks = bookmarks_rx.recv().await; - ui.busy_end(); - ui.shared_inflight.set(false); - - // A transport failure on any of the three means the daemon isn't up. - if matches!(shared, Ok(Err(_)) | Err(_)) - || matches!(invites, Ok(Err(_)) | Err(_)) - || matches!(bookmarks, Ok(Err(_)) | Err(_)) - { - ui.shared_loaded_at.set(None); - shared_unreachable(&ui); - return; - } - - let shared_items = match shared { - Ok(Ok(Response::Entries { entries })) => entries, - _ => Vec::new(), - }; - let invitations = match invites { - Ok(Ok(Response::Invitations { items })) => items, - _ => Vec::new(), - }; - let bookmark_items = match bookmarks { - Ok(Ok(Response::Bookmarks { items })) => items, - _ => Vec::new(), - }; - repaint_shared(&ui, &shared_items, &invitations, &bookmark_items); - ui.shared_loaded_at.set(Some(Instant::now())); - }); -} - -/// The daemon didn't answer the Shared page. Same still-starting vs. down split -/// as the other pages. -fn shared_unreachable(ui: &Rc) { - if service::is_failed() || !service::is_active() { - shared_status( - ui, - "network-offline-symbolic", - "Not connected", - "The Proton Drive mount service isn't running.", - true, - ); - return; - } - shared_status( - ui, - "folder-remote-symbolic", - "Connecting…", - "Waiting for the Proton Drive mount service to come up.", - false, - ); - let ui = ui.clone(); - glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || { - if ui.stack.visible_child_name().as_deref() == Some("shared") { - load_shared(&ui); - } - }); -} - -/// Rebuild the three Shared sections. Rows added last time are removed first — -/// adw groups have no clear-all — then each section is repopulated, showing a -/// dim placeholder row when empty. -fn repaint_shared( - ui: &Rc, - shared: &[DirEntry], - invitations: &[InvitationInfo], - bookmarks: &[BookmarkInfo], -) { - for (group, row) in ui.shared_rows.borrow_mut().drain(..) { - group.remove(&row); - } - ui.shared_content.set_visible_child_name("list"); - let mut rows: Vec<(adw::PreferencesGroup, gtk4::Widget)> = Vec::new(); - - // Shared with me: name + Leave. - if shared.is_empty() { - let row = dim_row("Nothing is shared with you."); - ui.shared_with_me_group.add(&row); - rows.push((ui.shared_with_me_group.clone(), row.upcast())); - } else { - for entry in shared { - let row = adw::ActionRow::builder().title(&entry.name).build(); - row.add_prefix(>k4::Image::from_icon_name(if entry.is_dir { - "folder-symbolic" - } else { - "text-x-generic-symbolic" - })); - let leave = gtk4::Button::builder() - .label("Leave") - .valign(gtk4::Align::Center) - .build(); - leave.add_css_class("flat"); - let ui_leave = ui.clone(); - let uid = entry.uid.clone(); - let name = entry.name.clone(); - leave.connect_clicked(move |_| prompt_leave_shared(&ui_leave, &uid, &name)); - row.add_suffix(&leave); - ui.shared_with_me_group.add(&row); - rows.push((ui.shared_with_me_group.clone(), row.upcast())); - } - } - - // Invitations: inviter + item, Accept / Reject. - if invitations.is_empty() { - let row = dim_row("No pending invitations."); - ui.invitations_group.add(&row); - rows.push((ui.invitations_group.clone(), row.upcast())); - } else { - for inv in invitations { - let item = inv - .name - .clone() - .unwrap_or_else(|| "a shared item".to_string()); - let row = adw::ActionRow::builder() - .title(&item) - .subtitle(format!("from {}", inv.inviter_email)) - .build(); - row.add_prefix(>k4::Image::from_icon_name(if inv.is_dir { - "folder-symbolic" - } else { - "text-x-generic-symbolic" - })); - let reject = gtk4::Button::builder() - .icon_name("window-close-symbolic") - .tooltip_text("Reject") - .valign(gtk4::Align::Center) - .build(); - reject.add_css_class("flat"); - let accept = gtk4::Button::builder() - .label("Accept") - .valign(gtk4::Align::Center) - .build(); - accept.add_css_class("suggested-action"); - let ui_acc = ui.clone(); - let id_acc = inv.id.clone(); - accept.connect_clicked(move |_| { - respond_invitation(&ui_acc, &id_acc, true); - }); - let ui_rej = ui.clone(); - let id_rej = inv.id.clone(); - reject.connect_clicked(move |_| { - respond_invitation(&ui_rej, &id_rej, false); - }); - row.add_suffix(&accept); - row.add_suffix(&reject); - ui.invitations_group.add(&row); - rows.push((ui.invitations_group.clone(), row.upcast())); - } - } - - // Bookmarks: name/URL, Open / Remove. - if bookmarks.is_empty() { - let row = dim_row("No saved bookmarks."); - ui.bookmarks_group.add(&row); - rows.push((ui.bookmarks_group.clone(), row.upcast())); - } else { - for bm in bookmarks { - let title = bm.name.clone().unwrap_or_else(|| "Shared link".to_string()); - let row = adw::ActionRow::builder() - .title(&title) - .subtitle(&bm.url) - .build(); - row.add_prefix(>k4::Image::from_icon_name("emblem-symbolic-link")); - let remove = gtk4::Button::builder() - .icon_name("user-trash-symbolic") - .tooltip_text("Remove bookmark") - .valign(gtk4::Align::Center) - .build(); - remove.add_css_class("flat"); - let open = gtk4::Button::builder() - .icon_name("external-link-symbolic") - .tooltip_text("Open in browser") - .valign(gtk4::Align::Center) - .build(); - open.add_css_class("flat"); - let url_open = bm.url.clone(); - open.connect_clicked(move |_| open_uri(&url_open)); - let ui_rm = ui.clone(); - let token = bm.token.clone(); - let name_rm = title.clone(); - remove.connect_clicked(move |_| prompt_remove_bookmark(&ui_rm, &token, &name_rm)); - row.add_suffix(&open); - row.add_suffix(&remove); - ui.bookmarks_group.add(&row); - rows.push((ui.bookmarks_group.clone(), row.upcast())); - } - } - - *ui.shared_rows.borrow_mut() = rows; -} - -/// Accept or reject an invitation, then reload the Shared page. -fn respond_invitation(ui: &Rc, id: &str, accept: bool) { - let req = if accept { - Request::AcceptInvitation { id: id.to_string() } - } else { - Request::RejectInvitation { id: id.to_string() } - }; - let (done, failed) = if accept { - ("Invitation accepted", "Couldn't accept the invitation") - } else { - ("Invitation rejected", "Couldn't reject the invitation") - }; - run_shared_mutation(ui, req, done, failed); -} - -/// Confirm, then leave a node shared with me. -fn prompt_leave_shared(ui: &Rc, uid: &str, name: &str) { - let win = ui_window(ui); - let dialog = adw::AlertDialog::builder() - .heading("Leave shared item") - .body(format!( - "Leave “{name}”? You'll lose access until you're invited again." - )) - .build(); - dialog.add_response("cancel", "Cancel"); - dialog.add_response("leave", "Leave"); - dialog.set_response_appearance("leave", adw::ResponseAppearance::Destructive); - dialog.set_default_response(Some("cancel")); - dialog.set_close_response("cancel"); - let ui = ui.clone(); - let uid = uid.to_string(); - dialog.connect_response(None, move |_, resp| { - if resp == "leave" { - run_shared_mutation( - &ui, - Request::LeaveShared { uid: uid.clone() }, - "Left shared item", - "Couldn't leave the shared item", - ); - } - }); - dialog.present(win.as_ref()); -} - -/// Confirm, then remove a saved bookmark. -fn prompt_remove_bookmark(ui: &Rc, token: &str, name: &str) { - let win = ui_window(ui); - let dialog = adw::AlertDialog::builder() - .heading("Remove bookmark") - .body(format!("Remove the bookmark for “{name}”?")) - .build(); - dialog.add_response("cancel", "Cancel"); - dialog.add_response("remove", "Remove"); - dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive); - dialog.set_default_response(Some("cancel")); - dialog.set_close_response("cancel"); - let ui = ui.clone(); - let token = token.to_string(); - dialog.connect_response(None, move |_, resp| { - if resp == "remove" { - run_shared_mutation( - &ui, - Request::DeleteBookmark { - token: token.clone(), - }, - "Bookmark removed", - "Couldn't remove the bookmark", - ); - } - }); - dialog.present(win.as_ref()); -} - -/// Prompt for a public-link URL (and optional password) and save it as a bookmark. -fn prompt_add_bookmark(ui: &Rc) { - let win = ui_window(ui); - let dialog = adw::AlertDialog::builder() - .heading("Add bookmark") - .body("Paste a Proton Drive public link to save it here.") - .build(); - let group = adw::PreferencesGroup::new(); - let url_row = adw::EntryRow::builder() - .title("Public link URL") - .activates_default(true) - .build(); - let pw_row = adw::PasswordEntryRow::builder() - .title("Password (if the link has one)") - .activates_default(true) - .build(); - group.add(&url_row); - group.add(&pw_row); - dialog.set_extra_child(Some(&group)); - dialog.add_response("cancel", "Cancel"); - dialog.add_response("add", "Add"); - dialog.set_response_appearance("add", adw::ResponseAppearance::Suggested); - dialog.set_default_response(Some("add")); - dialog.set_close_response("cancel"); - let ui = ui.clone(); - dialog.connect_response(None, move |_, resp| { - if resp != "add" { - return; - } - let url = url_row.text().trim().to_string(); - if url.is_empty() { - toast_error(&ui, "Couldn't add bookmark", "A URL is required."); - return; - } - let pw = pw_row.text().to_string(); - let password = if pw.is_empty() { None } else { Some(pw) }; - run_shared_mutation( - &ui, - Request::CreateBookmark { url, password }, - "Bookmark saved", - "Couldn't add the bookmark", - ); - }); - dialog.present(win.as_ref()); -} - -/// Run a mutation raised from the Shared page and reload the page on success. -fn run_shared_mutation(ui: &Rc, req: Request, done: &'static str, failed: &'static str) { - ui.busy_begin(); - let rx = spawn_request(ui.dirs.control_socket(), req); - let ui = ui.clone(); - let done = done.to_string(); - glib::spawn_future_local(async move { - let result = rx.recv().await; - ui.busy_end(); - match result { - Ok(Ok(Response::Ok { .. })) => { - load_shared(&ui); - toast(&ui, &done); - } - Ok(Ok(Response::Error { message })) => toast_error(&ui, failed, &message), - _ => toast_error(&ui, failed, "The mount service didn't respond."), - } - }); -} - -// ---- Devices page --------------------------------------------------------- - -/// Install the Devices page's retry button and the "Add Folder" action. -fn wire_devices(ui: &Rc, retry: >k4::Button, add_folder: >k4::Button) { - let ui_retry = ui.clone(); - retry.connect_clicked(move |_| { - service::restart(); - load_devices(&ui_retry); - }); - let ui_add = ui.clone(); - add_folder.connect_clicked(move |_| prompt_add_sync_folder(&ui_add)); -} - -/// Show a status page in place of the Devices list. -fn devices_status(ui: &Rc, icon: &str, title: &str, description: &str, retry: bool) { - ui.devices_status.set_icon_name(Some(icon)); - ui.devices_status.set_title(title); - ui.devices_status.set_description(Some(description)); - ui.devices_retry.set_visible(retry); - ui.devices_content.set_visible_child_name("status"); -} - -/// Fetch this machine's synced folders and the account's other devices, then -/// repaint both sections. The two requests are chained so a single unreachable -/// daemon collapses the whole page to a status view. -fn load_devices(ui: &Rc) { - if ui.devices_inflight.get() { - return; - } - ui.devices_inflight.set(true); - devices_status( - ui, - "computer-symbolic", - "Loading…", - "Reading your devices.", - false, - ); - ui.busy_begin(); - // The two lists are independent and the daemon serves requests concurrently, - // so fire both up front and collect them, rather than paying two round trips - // back to back. - let rx = spawn_request(ui.dirs.control_socket(), Request::ListSyncFolders); - let rx2 = spawn_request(ui.dirs.control_socket(), Request::ListDevices); - let ui = ui.clone(); - glib::spawn_future_local(async move { - let sync = rx.recv().await; - let devices_reply = rx2.recv().await; - let folders = match sync { - Ok(Ok(Response::SyncFolders { items })) => items, - // The daemon answered but not as expected — treat as empty and carry - // on to the devices list rather than failing the whole page. - Ok(Ok(_)) => Vec::new(), - Ok(Err(_)) | Err(_) => { - ui.busy_end(); - ui.devices_inflight.set(false); - ui.devices_loaded_at.set(None); - devices_unreachable(&ui); - return; - } - }; - // Daemon reachable: show the list and paint the synced-folders section. - ui.devices_content.set_visible_child_name("list"); - repaint_sync_folders(&ui, &folders); - - let devices = match devices_reply { - Ok(Ok(Response::Devices { items })) => items, - _ => Vec::new(), - }; - ui.busy_end(); - ui.devices_inflight.set(false); - repaint_devices(&ui, &devices); - ui.devices_loaded_at.set(Some(Instant::now())); - }); -} - -/// Refresh just the synced-folders section, with no status flash, no spinner and -/// no devices round-trip. Driven by the periodic tick while the Devices page is -/// on screen: a folder's sync progress is only live if something re-reads it. -/// -/// A hiccup leaves the page as it is — the next tick tries again, and a real -/// outage is reported by [`load_devices`] on the next visit. -fn refresh_sync_folders(ui: &Rc) { - if ui.devices_inflight.get() { - return; - } - ui.devices_inflight.set(true); - let rx = spawn_request(ui.dirs.control_socket(), Request::ListSyncFolders); - let ui = ui.clone(); - glib::spawn_future_local(async move { - let result = rx.recv().await; - ui.devices_inflight.set(false); - let Ok(Ok(Response::SyncFolders { items })) = result else { - return; - }; - // The page may have been navigated away from, or collapsed to a status - // view, while the request was in flight. - if ui.stack.visible_child_name().as_deref() == Some("devices") - && ui.devices_content.visible_child_name().as_deref() == Some("list") - { - repaint_sync_folders(&ui, &items); - } - }); -} - -/// The daemon didn't answer the Devices page. -fn devices_unreachable(ui: &Rc) { - if service::is_failed() || !service::is_active() { - devices_status( - ui, - "network-offline-symbolic", - "Not connected", - "The Proton Drive mount service isn't running.", - true, - ); - return; - } - devices_status( - ui, - "folder-remote-symbolic", - "Connecting…", - "Waiting for the Proton Drive mount service to come up.", - false, - ); - let ui = ui.clone(); - glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || { - if ui.stack.visible_child_name().as_deref() == Some("devices") { - load_devices(&ui); - } - }); -} - -/// Rebuild the "Other computers" section from a fresh listing. Empty is a normal -/// state (this machine may be the only device), so it shows a placeholder row -/// rather than collapsing the page. -/// -/// This machine's own device is filtered out. Deleting a device deletes its root -/// folder — every file backed up from it — and for *this* machine that would also -/// pull the ground out from under the synced folders listed directly above, so it -/// is not offered as a peer of "remove some other laptop". Removing this -/// computer's backup means removing its folders, each of which asks about its -/// cloud copy on its own terms. -fn repaint_devices(ui: &Rc, devices: &[DeviceInfo]) { - for row in ui.devices_rows.borrow_mut().drain(..) { - ui.devices_group.remove(&row); - } - let others: Vec<&DeviceInfo> = devices.iter().filter(|d| !d.this_device).collect(); - if others.is_empty() { - let row = adw::ActionRow::builder() - .title("No other computers") - .subtitle("Desktop apps syncing to this account appear here.") - .build(); - row.add_prefix(>k4::Image::from_icon_name("computer-symbolic")); - ui.devices_group.add(&row); - *ui.devices_rows.borrow_mut() = vec![row.upcast()]; - return; - } - let mut rows: Vec = Vec::new(); - for dev in others { - let row = adw::ActionRow::builder() - .title(&dev.name) - .subtitle(device_subtitle(dev)) - .build(); - row.add_prefix(>k4::Image::from_icon_name("computer-symbolic")); - let remove = gtk4::Button::builder() - .icon_name("user-trash-symbolic") - .tooltip_text("Remove this computer and everything it backed up") - .valign(gtk4::Align::Center) - .build(); - remove.add_css_class("flat"); - let rename = gtk4::Button::builder() - .icon_name("document-edit-symbolic") - .tooltip_text("Rename computer") - .valign(gtk4::Align::Center) - .build(); - rename.add_css_class("flat"); - let ui_ren = ui.clone(); - let uid_ren = dev.uid.clone(); - let name_ren = dev.name.clone(); - rename.connect_clicked(move |_| prompt_rename_device(&ui_ren, &uid_ren, &name_ren)); - let ui_rm = ui.clone(); - let uid_rm = dev.uid.clone(); - let name_rm = dev.name.clone(); - remove.connect_clicked(move |_| prompt_remove_device(&ui_rm, &uid_rm, &name_rm)); - row.add_suffix(&rename); - row.add_suffix(&remove); - ui.devices_group.add(&row); - rows.push(row.upcast()); - } - *ui.devices_rows.borrow_mut() = rows; -} - -/// A device row's subtitle: its platform, and when it last synced. A device that -/// has never synced says so — an unexplained missing date reads as a bug, and -/// "never" is the fact that tells the user this computer isn't backing anything up. -fn device_subtitle(dev: &DeviceInfo) -> String { - match dev.last_sync { - Some(secs) if secs > 0 => { - format!("{} · last synced {}", dev.device_type, activity_time(secs)) - } - _ => format!("{} · never synced", dev.device_type), - } -} - -/// Rebuild the "This computer" section from a fresh synced-folders listing. -fn repaint_sync_folders(ui: &Rc, folders: &[SyncFolderInfo]) { - for row in ui.devices_sync_rows.borrow_mut().drain(..) { - ui.devices_sync_group.remove(&row); - } - if folders.is_empty() { - let row = adw::ActionRow::builder() - .title("No synced folders") - .subtitle("Add a folder to back it up to this device.") - .build(); - row.add_prefix(>k4::Image::from_icon_name("folder-symbolic")); - ui.devices_sync_group.add(&row); - *ui.devices_sync_rows.borrow_mut() = vec![row.upcast()]; - return; - } - let mut rows: Vec = Vec::new(); - for f in folders { - // A running pass describes itself ("Uploading photo.jpg — 12 of 40"); - // otherwise fall back to the folder's resting state. - let status = match &f.progress { - Some(p) => sync_progress_label(p), - None => sync_state_label(&f.state).to_string(), - }; - // A queued switch is where the folder is *going*, which is what the user - // just asked for and is waiting to see happen — so it leads the subtitle - // instead of the mode the folder is still technically in. - let subtitle = match (f.pending_mode.as_deref(), f.mode.as_str()) { - (Some("ondemand"), _) => format!("Going on-demand · {status}"), - (Some(_), _) => format!("Switching to synced · {status}"), - (None, "ondemand") => format!("On-demand · {status}"), - (None, _) => format!("Synced · {status}"), - }; - let row = adw::ActionRow::builder() - .title(&f.local_path) - .subtitle(&subtitle) - .build(); - row.add_prefix(>k4::Image::from_icon_name("folder-symbolic")); - let id = f.id; - - // A pass that knows how far it has got shows it: the subtitle alone - // ("syncing photo.jpg — 12 of 40") never conveys that the end is near. This - // holds for scanning as much as applying — a big folder spends minutes there - // — but a folder syncing for the first time has no estimate to draw against - // (`total == 0`), and this row is repainted on the 2s tick, too slow for a - // pulse to read as motion. So it stays text-only until real counts exist. - if let Some(p) = &f.progress - && p.total > 0 - { - let bar = gtk4::ProgressBar::builder() - .fraction((p.done as f64 / p.total.max(p.done) as f64).min(1.0)) - .valign(gtk4::Align::Center) - .width_request(120) - .build(); - row.add_suffix(&bar); - } - - // Sync now — only meaningful for mirror folders (on-demand has no local - // copy to reconcile). The engine also syncs on file changes and on a - // 120s poll; this is the "don't wait" button. - if f.mode != "ondemand" { - let sync_now = gtk4::Button::builder() - .icon_name("view-refresh-symbolic") - .tooltip_text("Sync this folder now") - .valign(gtk4::Align::Center) - .build(); - sync_now.add_css_class("flat"); - let ui_sync = ui.clone(); - sync_now.connect_clicked(move |_| { - let rx = spawn_request( - ui_sync.dirs.control_socket(), - Request::SyncNow { id: Some(id) }, - ); - let ui_sync = ui_sync.clone(); - glib::spawn_future_local(async move { - match rx.recv().await { - Ok(Ok(Response::Ok { .. })) => toast(&ui_sync, "Syncing folder…"), - Ok(Ok(Response::Error { message })) => { - toast_error(&ui_sync, "Couldn't sync", &message) - } - _ => toast_error( - &ui_sync, - "Couldn't sync", - "The mount service didn't respond.", - ), - } - }); - }); - row.add_suffix(&sync_now); - } - - // On-demand toggle: off = full local copy (mirror), on = FUSE mount that - // frees the disk. Set the state before wiring the handler so painting the - // current mode doesn't fire a spurious request. - // - // A queued switch paints as already flipped: the daemon accepted the - // request and will act on it, so snapping the switch back to the current - // mode would read as "it didn't take" and invite the user to toggle again. - let target = f.pending_mode.as_deref().unwrap_or(&f.mode); - let ondemand = gtk4::Switch::builder() - .tooltip_text( - "On-demand: free this disk by keeping the files in Proton Drive only, \ - fetching each as you open it. Turn off to download them back and keep a \ - full local copy.", - ) - .valign(gtk4::Align::Center) - .active(target == "ondemand") - .build(); - let ui_mode = ui.clone(); - ondemand.connect_state_set(move |_, on| { - set_sync_folder_mode(&ui_mode, id, if on { "ondemand" } else { "mirror" }); - glib::Propagation::Proceed - }); - row.add_suffix(&ondemand); - - let remove = gtk4::Button::builder() - .icon_name("user-trash-symbolic") - .tooltip_text("Stop syncing this folder") - .valign(gtk4::Align::Center) - .build(); - remove.add_css_class("flat"); - let ui_rm = ui.clone(); - let path = f.local_path.clone(); - // The folder's *current* mode, not a queued one: a switch that hasn't - // landed yet has not moved the files anywhere. - let ondemand = f.mode == "ondemand"; - remove.connect_clicked(move |_| prompt_remove_sync_folder(&ui_rm, id, &path, ondemand)); - row.add_suffix(&remove); - ui.devices_sync_group.add(&row); - rows.push(row.upcast()); - } - *ui.devices_sync_rows.borrow_mut() = rows; -} - -/// Human label for a synced folder's `state` column. -fn sync_state_label(state: &str) -> &str { - match state { - "syncing" => "syncing…", - "error" => "sync error", - "conflict" => "needs attention", - _ => "up to date", - } -} - -/// Human label for a sync pass in flight: what it is doing, to which file, and -/// how far along it is. Neither phase's total is exact — the scan's is an estimate -/// from the last pass, and the applying total grows as deeper paths are classified -/// — so both read "12 of 40" rather than a percentage that could go backwards. -fn sync_progress_label(p: &SyncProgress) -> String { - match p.phase { - // Before the first pass finishes there is no estimate, so the count would - // be "checked 12 of 12" — worse than saying nothing. - SyncPhase::Scanning if p.total == 0 => "checking for changes…".to_string(), - SyncPhase::Scanning => format!( - "checking for changes — {} of {}", - p.done, - p.total.max(p.done) - ), - SyncPhase::Applying => { - let count = format!("{} of {}", p.done + 1, p.total.max(p.done + 1)); - if p.current.is_empty() { - format!("syncing {count}") - } else { - format!("syncing {} — {count}", p.current) - } - } - } -} - -/// Pick a local folder and hand it to the daemon to sync to this device. -fn prompt_add_sync_folder(ui: &Rc) { - let win = ui_window(ui); - let dialog = gtk4::FileDialog::builder() - .title("Add Folder to Sync") - .build(); - let ui = ui.clone(); - dialog.select_folder(win.as_ref(), gio::Cancellable::NONE, move |res| { - let Ok(folder) = res else { return }; - let Some(local_path) = folder.path().and_then(|p| p.to_str().map(str::to_string)) else { - return; - }; - let rx = spawn_request( - ui.dirs.control_socket(), - Request::AddSyncFolder { - local_path: local_path.clone(), - }, - ); - let ui = ui.clone(); - glib::spawn_future_local(async move { - match rx.recv().await { - Ok(Ok(Response::Ok { .. })) => { - // The daemon acks before the row exists — registering the - // device and creating the remote folder is off-socket network - // work. A fixed delay here either fires too early (empty list) - // or too late (row flashes in). Instead let the periodic - // `refresh_sync_folders` tick pick the row up whenever it - // actually lands, which is what keeps a running pass live too. - toast(&ui, "Syncing folder…"); - } - Ok(Ok(Response::Error { message })) => { - toast_error(&ui, "Couldn't add folder", &message) - } - _ => toast_error( - &ui, - "Couldn't add folder", - "The mount service didn't respond.", - ), - } - }); - }); -} - -/// Flip a synced folder between `mirror` and `ondemand`. Reloads after so the -/// row's subtitle and switch reflect the daemon's real state (the request may be -/// rejected, e.g. switching to on-demand while a folder is mid-sync). -fn set_sync_folder_mode(ui: &Rc, id: i64, mode: &str) { - let rx = spawn_request( - ui.dirs.control_socket(), - Request::SetSyncFolderMode { - id, - mode: mode.to_string(), - }, - ); - let ui = ui.clone(); - glib::spawn_future_local(async move { - match rx.recv().await { - Ok(Ok(Response::Ok { message })) => toast(&ui, &message), - Ok(Ok(Response::Error { message })) => { - toast_error(&ui, "Couldn't change mode", &message) - } - _ => toast_error( - &ui, - "Couldn't change mode", - "The mount service didn't respond.", - ), - } - if ui.stack.visible_child_name().as_deref() == Some("devices") { - load_devices(&ui); - } - }); -} - -/// Confirm, then stop syncing a folder. Offers to also delete the cloud copy. -fn prompt_remove_sync_folder(ui: &Rc, id: i64, path: &str, ondemand: bool) { - let win = ui_window(ui); - // The two modes leave the user in opposite places, so they can't share a - // sentence. A mirror folder's files are already on this disk and stay there. - // An on-demand folder's are not: the path is a mount over content that lives - // in Proton Drive, so unmounting it leaves an empty directory — which is a - // nasty surprise if the dialog claimed the local files were safe, and is - // recoverable only by turning On-demand off *first* and letting it download. - let body = if ondemand { - format!( - "Stop syncing “{path}”?\n\nThis folder is on-demand: its files live in Proton \ - Drive, not on this disk, so the folder will be empty once it is unmounted. To \ - keep a local copy, cancel, turn off On-demand, and wait for the download to \ - finish before removing it." - ) - } else { - format!( - "Stop syncing “{path}”?\n\nThe local files stay on this disk and simply stop \ - being synced. Choose whether to also delete the copy in Proton Drive." - ) - }; - let dialog = adw::AlertDialog::builder() - .heading("Stop syncing folder") - .body(body) - .build(); - let group = adw::PreferencesGroup::new(); - let delete_remote = adw::SwitchRow::builder() - .title("Also delete from Proton Drive") - .subtitle(if ondemand { - "Deletes the only copy of these files." - } else { - "The local copy is unaffected." - }) - .active(false) - .build(); - group.add(&delete_remote); - dialog.set_extra_child(Some(&group)); - dialog.add_response("cancel", "Cancel"); - dialog.add_response("remove", "Stop Syncing"); - dialog.set_response_appearance("remove", 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 == "remove" { - run_devices_mutation( - &ui, - Request::RemoveSyncFolder { - id, - delete_remote: delete_remote.is_active(), - }, - "Stopped syncing folder", - "Couldn't stop syncing the folder", - ); - } - }); - dialog.present(win.as_ref()); -} - -/// Prompt for a new device name and rename it. -fn prompt_rename_device(ui: &Rc, uid: &str, current: &str) { - let win = ui_window(ui); - let dialog = adw::AlertDialog::builder() - .heading("Rename device") - .body(format!("Rename “{current}”.")) - .build(); - let group = adw::PreferencesGroup::new(); - let row = adw::EntryRow::builder() - .title("New name") - .activates_default(true) - .build(); - row.set_text(current); - group.add(&row); - dialog.set_extra_child(Some(&group)); - dialog.add_response("cancel", "Cancel"); - dialog.add_response("rename", "Rename"); - dialog.set_response_appearance("rename", adw::ResponseAppearance::Suggested); - dialog.set_default_response(Some("rename")); - dialog.set_close_response("cancel"); - let ui = ui.clone(); - let uid = uid.to_string(); - dialog.connect_response(None, move |_, resp| { - if resp != "rename" { - return; - } - let name = row.text().trim().to_string(); - if name.is_empty() { - toast_error(&ui, "Couldn't rename device", "A name is required."); - return; - } - run_devices_mutation( - &ui, - Request::RenameDevice { - uid: uid.clone(), - name, - }, - "Device renamed", - "Couldn't rename the device", - ); - }); - dialog.present(win.as_ref()); -} - -/// Confirm, then remove (deregister) a device. -fn prompt_remove_device(ui: &Rc, uid: &str, name: &str) { - let win = ui_window(ui); - let dialog = adw::AlertDialog::builder() - .heading("Remove computer") - .body(format!( - "Remove “{name}” from this account?\n\nEverything it backed up to Proton Drive is \ - deleted along with it. The files on that computer itself are not touched — but \ - this cannot be undone from here." - )) - .build(); - dialog.add_response("cancel", "Cancel"); - dialog.add_response("remove", "Remove"); - dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive); - dialog.set_default_response(Some("cancel")); - dialog.set_close_response("cancel"); - let ui = ui.clone(); - let uid = uid.to_string(); - dialog.connect_response(None, move |_, resp| { - if resp == "remove" { - run_devices_mutation( - &ui, - Request::DeleteDevice { uid: uid.clone() }, - "Device removed", - "Couldn't remove the device", - ); - } - }); - dialog.present(win.as_ref()); -} - -/// Run a mutation raised from the Devices page and reload the page on success. -fn run_devices_mutation(ui: &Rc, req: Request, done: &'static str, failed: &'static str) { - ui.busy_begin(); - let rx = spawn_request(ui.dirs.control_socket(), req); - let ui = ui.clone(); - let done = done.to_string(); - glib::spawn_future_local(async move { - let result = rx.recv().await; - ui.busy_end(); - match result { - Ok(Ok(Response::Ok { .. })) => { - load_devices(&ui); - toast(&ui, &done); - } - Ok(Ok(Response::Error { message })) => toast_error(&ui, failed, &message), - _ => toast_error(&ui, failed, "The mount service didn't respond."), - } - }); -} - -// ---- Shared (by me) page -------------------------------------------------- - -/// Widgets the Shared (by-me) page's load/repaint touch. -struct SharedByMeWidgets { - content: gtk4::Stack, - status: adw::StatusPage, - group: adw::PreferencesGroup, - retry: gtk4::Button, - refresh: gtk4::Button, -} - -/// The Shared page: one section listing the items I have shared with others — -/// each row summarizing who has access and carrying its public link (copy/open) -/// and a Manage shortcut into the per-node Share dialog. -fn build_shared_by_me_page() -> (gtk4::Widget, SharedByMeWidgets) { - let title = gtk4::Label::builder() - .label("Shared") - .halign(gtk4::Align::Start) - .hexpand(true) - .build(); - title.add_css_class("title-2"); - let refresh = refresh_button(); - let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); - header.set_hexpand(true); - header.append(&title); - header.append(&refresh); - - let group = adw::PreferencesGroup::new(); - let clamp = adw::Clamp::builder().child(&group).build(); - let scroll = gtk4::ScrolledWindow::builder() - .vexpand(true) - .child(&clamp) - .build(); - - let retry = gtk4::Button::builder() - .label("Retry") - .halign(gtk4::Align::Center) - .build(); - retry.add_css_class("pill"); - retry.add_css_class("suggested-action"); - retry.set_visible(false); - let status = adw::StatusPage::builder() - .icon_name("emblem-shared-symbolic") - .vexpand(true) - .child(&retry) - .build(); - status.add_css_class("compact"); - - let content = gtk4::Stack::new(); - content.set_vexpand(true); - content.set_transition_type(gtk4::StackTransitionType::Crossfade); - content.add_named(&scroll, Some("list")); - content.add_named(&status, Some("status")); - - let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12); - inner.set_margin_top(12); - inner.set_margin_bottom(12); - inner.set_margin_start(12); - inner.set_margin_end(12); - inner.append(&header); - inner.append(&content); - - ( - inner.upcast(), - SharedByMeWidgets { - content, - status, - group, - retry, - refresh, - }, - ) -} - -/// Install the Shared (by-me) page's retry button. -fn wire_shared_by_me(ui: &Rc, retry: >k4::Button) { - let ui_retry = ui.clone(); - retry.connect_clicked(move |_| { - service::restart(); - load_shared_by_me(&ui_retry); - }); -} - -/// Show a status page in place of the Shared (by-me) list. -fn shared_by_me_status(ui: &Rc, icon: &str, title: &str, description: &str, retry: bool) { - ui.shared_by_me_status.set_icon_name(Some(icon)); - ui.shared_by_me_status.set_title(title); - ui.shared_by_me_status.set_description(Some(description)); - ui.shared_by_me_retry.set_visible(retry); - ui.shared_by_me_content.set_visible_child_name("status"); -} - -/// Fetch the shared-by-me listing and repaint the page. -fn load_shared_by_me(ui: &Rc) { - if ui.shared_by_me_inflight.get() { - return; - } - ui.shared_by_me_inflight.set(true); - shared_by_me_status( - ui, - "emblem-shared-symbolic", - "Loading…", - "Reading what you've shared.", - false, - ); - ui.busy_begin(); - let rx = spawn_request(ui.dirs.control_socket(), Request::ListSharedByMe); - let ui = ui.clone(); - glib::spawn_future_local(async move { - let result = rx.recv().await; - ui.busy_end(); - ui.shared_by_me_inflight.set(false); - match result { - Ok(Ok(Response::SharedByMe { items })) => { - repaint_shared_by_me(&ui, &items); - ui.shared_by_me_loaded_at.set(Some(Instant::now())); - } - Ok(Ok(Response::Error { message })) => shared_by_me_status( - &ui, - "dialog-warning-symbolic", - "Couldn't read your shares", - &message, - false, - ), - Ok(Ok(_)) => shared_by_me_status( - &ui, - "dialog-warning-symbolic", - "Couldn't read your shares", - "Unexpected reply from the mount service.", - false, - ), - Ok(Err(_)) | Err(_) => { - ui.shared_by_me_loaded_at.set(None); - shared_by_me_unreachable(&ui); - } - } - }); -} - -/// The daemon didn't answer the Shared (by-me) page. -fn shared_by_me_unreachable(ui: &Rc) { - if service::is_failed() || !service::is_active() { - shared_by_me_status( - ui, - "network-offline-symbolic", - "Not connected", - "The Proton Drive mount service isn't running.", - true, - ); - return; - } - shared_by_me_status( - ui, - "folder-remote-symbolic", - "Connecting…", - "Waiting for the Proton Drive mount service to come up.", - false, - ); - let ui = ui.clone(); - glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || { - if ui.stack.visible_child_name().as_deref() == Some("sharedbyme") { - load_shared_by_me(&ui); - } - }); -} - -/// Rebuild the Shared (by-me) section from a fresh listing. -fn repaint_shared_by_me(ui: &Rc, items: &[SharedItem]) { - for row in ui.shared_by_me_rows.borrow_mut().drain(..) { - ui.shared_by_me_group.remove(&row); - } - if items.is_empty() { - shared_by_me_status( - ui, - "emblem-shared-symbolic", - "Nothing shared yet", - "Items you share with people or by link show up here.", - false, - ); - return; - } - ui.shared_by_me_content.set_visible_child_name("list"); - let mut rows: Vec = Vec::new(); - for item in items { - let row = adw::ActionRow::builder() - .title(&item.name) - .subtitle(shared_item_summary(item)) - .build(); - row.add_prefix(>k4::Image::from_icon_name(if item.is_dir { - "folder-symbolic" - } else { - "text-x-generic-symbolic" - })); - - // Copy / open the public link, when there is one with a recovered URL. - if let Some(url) = item.link.as_ref().and_then(|l| l.url.clone()) { - let copy = gtk4::Button::builder() - .icon_name("edit-copy-symbolic") - .tooltip_text("Copy link") - .valign(gtk4::Align::Center) - .build(); - copy.add_css_class("flat"); - let ui_copy = ui.clone(); - let url_copy = url.clone(); - copy.connect_clicked(move |btn| { - btn.clipboard().set_text(&url_copy); - toast(&ui_copy, "Link copied"); - }); - let open = gtk4::Button::builder() - .icon_name("external-link-symbolic") - .tooltip_text("Open link") - .valign(gtk4::Align::Center) - .build(); - open.add_css_class("flat"); - let url_open = url.clone(); - open.connect_clicked(move |_| open_uri(&url_open)); - row.add_suffix(©); - row.add_suffix(&open); - } - - // Manage opens the per-node Share dialog — only when the node's path is - // known (it has been browsed to this session); the dialog is path-keyed. - if !item.path.is_empty() { - let manage = gtk4::Button::builder() - .label("Manage") - .valign(gtk4::Align::Center) - .build(); - manage.add_css_class("flat"); - let ui_manage = ui.clone(); - let entry = shared_item_as_entry(item); - manage.connect_clicked(move |_| open_share_dialog(&ui_manage, &entry)); - row.add_suffix(&manage); - } - - ui.shared_by_me_group.add(&row); - rows.push(row.upcast()); - } - *ui.shared_by_me_rows.borrow_mut() = rows; -} - -/// A one-line summary of who can reach a shared item, for its row subtitle. -fn shared_item_summary(item: &SharedItem) -> String { - let mut parts = Vec::new(); - if item.member_count > 0 { - parts.push(format!( - "{} {}", - item.member_count, - if item.member_count == 1 { - "person" - } else { - "people" - } - )); - } - if item.invite_count > 0 { - parts.push(format!("{} pending", item.invite_count)); - } - if item.link.is_some() { - parts.push("Public link".to_string()); - } - if parts.is_empty() { - "Shared".to_string() - } else { - parts.join(" · ") - } -} - -/// Build a [`DirEntry`] from a [`SharedItem`] so the path-keyed Share dialog can -/// open on it. Only used when the item's path is known. -fn shared_item_as_entry(item: &SharedItem) -> DirEntry { - DirEntry { - name: item.name.clone(), - is_dir: item.is_dir, - size: 0, - modified: 0, - pinned: false, - cached: false, - uid: item.uid.clone(), - path: item.path.clone(), - } -} - -// ---- Activity page -------------------------------------------------------- - -/// Widgets the Activity page's load/repaint touch. -struct ActivityWidgets { - content: gtk4::Stack, - status: adw::StatusPage, - group: adw::PreferencesGroup, - retry: gtk4::Button, - refresh: gtk4::Button, -} - -/// The Activity page: a newest-first feed of the mutations and transfers the -/// daemon performed this session (uploads, deletes, shares, …). -fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) { - let title = gtk4::Label::builder() - .label("Activity") - .halign(gtk4::Align::Start) - .hexpand(true) - .build(); - title.add_css_class("title-2"); - let refresh = refresh_button(); - let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); - header.set_hexpand(true); - header.append(&title); - header.append(&refresh); - - let group = adw::PreferencesGroup::new(); - let clamp = adw::Clamp::builder().child(&group).build(); - let scroll = gtk4::ScrolledWindow::builder() - .vexpand(true) - .child(&clamp) - .build(); - - let retry = gtk4::Button::builder() - .label("Retry") - .halign(gtk4::Align::Center) - .build(); - retry.add_css_class("pill"); - retry.add_css_class("suggested-action"); - retry.set_visible(false); - let status = adw::StatusPage::builder() - .icon_name("document-open-recent-symbolic") - .vexpand(true) - .child(&retry) - .build(); - status.add_css_class("compact"); - - let content = gtk4::Stack::new(); - content.set_vexpand(true); - content.set_transition_type(gtk4::StackTransitionType::Crossfade); - content.add_named(&scroll, Some("list")); - content.add_named(&status, Some("status")); - - let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12); - inner.set_margin_top(12); - inner.set_margin_bottom(12); - inner.set_margin_start(12); - inner.set_margin_end(12); - inner.append(&header); - inner.append(&content); - - ( - inner.upcast(), - ActivityWidgets { - content, - status, - group, - retry, - refresh, - }, - ) -} - -/// Install the Activity page's retry button. -fn wire_activity(ui: &Rc, retry: >k4::Button) { - let ui_retry = ui.clone(); - retry.connect_clicked(move |_| { - service::restart(); - load_activity(&ui_retry); - }); -} - -/// Show a status page in place of the Activity list. -fn activity_status(ui: &Rc, icon: &str, title: &str, description: &str, retry: bool) { - ui.activity_status.set_icon_name(Some(icon)); - ui.activity_status.set_title(title); - ui.activity_status.set_description(Some(description)); - ui.activity_retry.set_visible(retry); - ui.activity_content.set_visible_child_name("status"); - // The list is no longer what is on screen, so the next repaint must not skip - // itself as a no-op and leave this status view up. - *ui.activity_key.borrow_mut() = None; -} - -/// Refresh the Activity feed in place, with no status flash and no spinner. -/// Driven by the periodic tick while the page is on screen, so a running sync -/// pass fills the feed as it works rather than only once it is done. Anything -/// other than a good answer leaves the rows alone until the next tick. -fn refresh_activity(ui: &Rc) { - if ui.activity_inflight.get() { - return; - } - ui.activity_inflight.set(true); - let rx = spawn_request( - ui.dirs.control_socket(), - Request::ListActivity { limit: 200 }, - ); - let ui = ui.clone(); - glib::spawn_future_local(async move { - let result = rx.recv().await; - ui.activity_inflight.set(false); - // The page may have been navigated away from while the request was in - // flight. - if let Ok(Ok(Response::Activity { items })) = result - && ui.stack.visible_child_name().as_deref() == Some("activity") - { - repaint_activity(&ui, &items); - } - }); -} - -fn load_activity(ui: &Rc) { - if ui.activity_inflight.get() { - return; - } - ui.activity_inflight.set(true); - activity_status( - ui, - "document-open-recent-symbolic", - "Loading…", - "Reading recent activity.", - false, - ); - ui.busy_begin(); - let rx = spawn_request( - ui.dirs.control_socket(), - Request::ListActivity { limit: 200 }, - ); - let ui = ui.clone(); - glib::spawn_future_local(async move { - let result = rx.recv().await; - ui.busy_end(); - ui.activity_inflight.set(false); - match result { - Ok(Ok(Response::Activity { items })) => repaint_activity(&ui, &items), - Ok(Ok(Response::Error { message })) => activity_status( - &ui, - "dialog-warning-symbolic", - "Couldn't read activity", - &message, - false, - ), - Ok(Ok(_)) => activity_status( - &ui, - "dialog-warning-symbolic", - "Couldn't read activity", - "Unexpected reply from the mount service.", - false, - ), - Ok(Err(_)) | Err(_) => activity_unreachable(&ui), - } - }); -} - -/// The daemon didn't answer the Activity page. -fn activity_unreachable(ui: &Rc) { - if service::is_failed() || !service::is_active() { - activity_status( - ui, - "network-offline-symbolic", - "Not connected", - "The Proton Drive mount service isn't running.", - true, - ); - return; - } - activity_status( - ui, - "folder-remote-symbolic", - "Connecting…", - "Waiting for the Proton Drive mount service to come up.", - false, - ); - let ui = ui.clone(); - glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || { - if ui.stack.visible_child_name().as_deref() == Some("activity") { - load_activity(&ui); - } - }); -} - -/// A cheap fingerprint of a feed: how many entries, and what the newest one is. -/// The log is append-only and newest-first, so a feed with the same length and -/// the same newest entry is the same feed. -fn activity_key(items: &[ActivityEntry]) -> String { - match items.first() { - Some(a) => format!("{}:{}:{}:{}", items.len(), a.time, a.target, a.detail), - None => String::new(), - } -} - -/// Rebuild the Activity section from a fresh log, unless it is already showing -/// exactly this feed. -fn repaint_activity(ui: &Rc, items: &[ActivityEntry]) { - let key = activity_key(items); - if ui.activity_key.borrow().as_deref() == Some(key.as_str()) { - return; - } - *ui.activity_key.borrow_mut() = Some(key); - for row in ui.activity_rows.borrow_mut().drain(..) { - ui.activity_group.remove(&row); - } - if items.is_empty() { - activity_status( - ui, - "document-open-recent-symbolic", - "Nothing yet", - "Uploads, moves, shares and other changes appear here as they happen.", - false, - ); - return; - } - ui.activity_content.set_visible_child_name("list"); - let mut rows: Vec = Vec::new(); - for a in items { - let mut subtitle = activity_time(a.time); - if !a.detail.is_empty() { - subtitle = format!("{subtitle} · {}", a.detail); - } - let row = adw::ActionRow::builder() - .title(format!("{} {}", activity_verb(a.kind), a.target)) - .subtitle(subtitle) - .build(); - let icon = if a.ok { - activity_icon(a.kind) - } else { - "dialog-warning-symbolic" - }; - row.add_prefix(>k4::Image::from_icon_name(icon)); - row.set_activatable(false); - ui.activity_group.add(&row); - rows.push(row.upcast()); - } - *ui.activity_rows.borrow_mut() = rows; -} - -/// A human verb for an activity kind, used as the row title's lead word. -fn activity_verb(kind: ActivityKind) -> &'static str { - match kind { - ActivityKind::Upload => "Uploaded", - ActivityKind::Download => "Downloaded", - ActivityKind::Sync => "Synced", - ActivityKind::Rename => "Renamed", - ActivityKind::Move => "Moved", - ActivityKind::CreateFolder => "Created folder", - ActivityKind::Trash => "Trashed", - ActivityKind::Restore => "Restored", - ActivityKind::DeleteForever => "Deleted", - ActivityKind::EmptyTrash => "Emptied trash —", - ActivityKind::Share => "Shared", - ActivityKind::PublicLink => "Linked", - ActivityKind::Unshare => "Unshared", - } -} - -/// A themed icon for an activity kind. -fn activity_icon(kind: ActivityKind) -> &'static str { - match kind { - ActivityKind::Upload => "document-send-symbolic", - ActivityKind::Download => "document-save-symbolic", - ActivityKind::Sync => "emblem-synchronizing-symbolic", - ActivityKind::Rename => "document-edit-symbolic", - ActivityKind::Move => "go-jump-symbolic", - ActivityKind::CreateFolder => "folder-new-symbolic", - ActivityKind::Trash | ActivityKind::DeleteForever | ActivityKind::EmptyTrash => { - "user-trash-symbolic" - } - ActivityKind::Restore => "edit-undo-symbolic", - ActivityKind::Share | ActivityKind::PublicLink => "emblem-shared-symbolic", - ActivityKind::Unshare => "action-unavailable-symbolic", - } -} - -/// Format an epoch-seconds timestamp for the Activity feed, in local time. -fn activity_time(secs: i64) -> String { - glib::DateTime::from_unix_local(secs) - .and_then(|dt| dt.format("%b %-d, %H:%M")) - .map(|s| s.to_string()) - .unwrap_or_default() -} - -/// A dim, non-interactive placeholder row for an empty section. -fn dim_row(text: &str) -> adw::ActionRow { - let row = adw::ActionRow::builder().title(text).build(); - row.add_css_class("dim-label"); - row.set_activatable(false); - row -} - -/// Open a URL in the user's default browser. -fn open_uri(url: &str) { - if let Err(e) = gio::AppInfo::launch_default_for_uri(url, None::<&gio::AppLaunchContext>) { - tracing::warn!("open uri {url}: {e}"); - } -} - -// ---- per-node Share dialog ------------------------------------------------ - -/// The roles offered in the Share dialog's dropdowns, in index order. -const SHARE_ROLES: [&str; 3] = ["Viewer", "Editor", "Admin"]; - -/// Map a role dropdown index to the wire role string. -fn role_index_to_wire(idx: u32) -> &'static str { - match idx { - 1 => "editor", - 2 => "admin", - _ => "viewer", - } -} - -/// Map a wire role string to its dropdown index. -fn role_wire_to_index(role: &str) -> u32 { - match role { - "editor" => 1, - "admin" => 2, - _ => 0, - } -} - -/// The mutable state behind an open Share dialog, so the invite/role/link -/// handlers can rebuild the people and link sections after each change without -/// tearing the whole dialog down. -struct ShareDialog { - ui: Rc, - /// The node's mountpoint-relative path — how every request addresses it. - rel: String, - people: adw::PreferencesGroup, - link_group: adw::PreferencesGroup, - people_rows: RefCell>, - link_rows: RefCell>, -} - -/// Open the per-node Share dialog: invite Proton/external users, manage who has -/// access and their roles, and create/copy/remove a public link. -fn open_share_dialog(ui: &Rc, entry: &DirEntry) { - if !*ui.mounted.borrow() { - toast_error(ui, "Can't share", "Proton Drive isn't connected."); - return; - } - let rel = entry_rel(ui, entry); - - let toolbar = adw::ToolbarView::new(); - let header = adw::HeaderBar::new(); - header.set_title_widget(Some(&adw::WindowTitle::new("Share", &entry.name))); - toolbar.add_top_bar(&header); - - // Invite section: emails + role + optional message. - let invite_group = adw::PreferencesGroup::builder() - .title("Invite people") - .description("Proton and non-Proton email addresses, separated by spaces or commas.") - .build(); - let email_row = adw::EntryRow::builder().title("Email addresses").build(); - let role_model = gtk4::StringList::new(&SHARE_ROLES); - let role_drop = gtk4::DropDown::builder() - .model(&role_model) - .selected(0) - .valign(gtk4::Align::Center) - .build(); - let role_wrap = adw::ActionRow::builder().title("Role").build(); - role_wrap.add_suffix(&role_drop); - let message_row = adw::EntryRow::builder().title("Message (optional)").build(); - let invite_btn = gtk4::Button::builder() - .label("Send Invitations") - .halign(gtk4::Align::End) - .margin_top(6) - .build(); - invite_btn.add_css_class("suggested-action"); - let invite_wrap = adw::PreferencesRow::builder() - .activatable(false) - .child(&invite_btn) - .build(); - invite_group.add(&email_row); - invite_group.add(&role_wrap); - invite_group.add(&message_row); - invite_group.add(&invite_wrap); - - let people = adw::PreferencesGroup::builder() - .title("People with access") - .build(); - let link_group = adw::PreferencesGroup::builder() - .title("Public link") - .build(); - - let content = gtk4::Box::new(gtk4::Orientation::Vertical, 18); - content.set_margin_top(12); - content.set_margin_bottom(12); - content.set_margin_start(12); - content.set_margin_end(12); - content.append(&invite_group); - content.append(&people); - content.append(&link_group); - let clamp = adw::Clamp::builder().child(&content).build(); - let scroll = gtk4::ScrolledWindow::builder() - .vexpand(true) - .hscrollbar_policy(gtk4::PolicyType::Never) - .child(&clamp) - .build(); - toolbar.set_content(Some(&scroll)); - - let dialog = adw::Dialog::builder() - .title("Share") - .content_width(480) - .content_height(560) - .child(&toolbar) - .build(); - - let state = Rc::new(ShareDialog { - ui: ui.clone(), - rel, - people, - link_group, - people_rows: RefCell::new(Vec::new()), - link_rows: RefCell::new(Vec::new()), - }); - - // Enter in either free-text field sends the invitations, no mouse needed. - let btn = invite_btn.clone(); - email_row.connect_entry_activated(move |_| btn.emit_clicked()); - let btn = invite_btn.clone(); - message_row.connect_entry_activated(move |_| btn.emit_clicked()); - - // Invite button. - let state_inv = state.clone(); - invite_btn.connect_clicked(move |_| { - let raw = email_row.text(); - let emails: Vec = raw - .split([',', ' ', ';']) - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(str::to_string) - .collect(); - if emails.is_empty() { - toast_error(&state_inv.ui, "Couldn't share", "Enter at least one email."); - return; - } - let role = role_index_to_wire(role_drop.selected()).to_string(); - let msg = message_row.text().trim().to_string(); - let message = if msg.is_empty() { None } else { Some(msg) }; - let email_clear = email_row.clone(); - let msg_clear = message_row.clone(); - share_dialog_op( - &state_inv, - Request::ShareNode { - path: state_inv.rel.clone(), - emails, - role, - message, - }, - "Invitations sent", - "Couldn't send invitations", - Some(Box::new(move || { - email_clear.set_text(""); - msg_clear.set_text(""); - })), - ); - }); - - share_dialog_reload(&state); - dialog.present(ui_window(ui).as_ref()); -} - -/// Re-fetch the node's share and rebuild the people + public-link sections. -fn share_dialog_reload(state: &Rc) { - let rx = spawn_request( - state.ui.dirs.control_socket(), - Request::ListShare { - path: state.rel.clone(), - }, - ); - let state = state.clone(); - glib::spawn_future_local(async move { - match rx.recv().await { - Ok(Ok(Response::Share { entries, link })) => { - repaint_share_people(&state, &entries); - repaint_share_link(&state, link.as_ref()); - } - Ok(Ok(Response::Error { message })) => { - toast_error(&state.ui, "Couldn't load sharing", &message) - } - _ => toast_error( - &state.ui, - "Couldn't load sharing", - "The mount service didn't respond.", - ), - } - }); -} - -/// Rebuild the "People with access" rows from a fresh share listing. -fn repaint_share_people(state: &Rc, entries: &[ShareEntry]) { - for row in state.people_rows.borrow_mut().drain(..) { - state.people.remove(&row); - } - let mut rows: Vec = Vec::new(); - if entries.is_empty() { - let row = dim_row("No one else has access yet."); - state.people.add(&row); - rows.push(row.upcast()); - *state.people_rows.borrow_mut() = rows; - return; - } - for entry in entries { - let subtitle = match entry.kind { - ShareEntryKind::Member => "Member".to_string(), - ShareEntryKind::ProtonInvite => "Invited (pending)".to_string(), - ShareEntryKind::ExternalInvite => "Invited (external, pending)".to_string(), - }; - let row = adw::ActionRow::builder() - .title(&entry.email) - .subtitle(&subtitle) - .build(); - - // External invites can't have their role changed; show it read-only. - // Members and Proton invites get a role dropdown. - if matches!( - entry.kind, - ShareEntryKind::Member | ShareEntryKind::ProtonInvite - ) { - let model = gtk4::StringList::new(&SHARE_ROLES); - let drop = gtk4::DropDown::builder() - .model(&model) - .selected(role_wire_to_index(&entry.role)) - .valign(gtk4::Align::Center) - .build(); - let state_role = state.clone(); - let id = entry.id.clone(); - let kind = entry.kind; - drop.connect_selected_notify(move |d| { - share_dialog_op( - &state_role, - Request::UpdateShareRole { - path: state_role.rel.clone(), - id: id.clone(), - kind, - role: role_index_to_wire(d.selected()).to_string(), - }, - "Role updated", - "Couldn't update the role", - None, - ); - }); - row.add_suffix(&drop); - } else { - let label = gtk4::Label::builder() - .label(capitalize(&entry.role)) - .valign(gtk4::Align::Center) - .build(); - label.add_css_class("dim-label"); - row.add_suffix(&label); - } - - let remove = gtk4::Button::builder() - .icon_name("user-trash-symbolic") - .tooltip_text("Remove access") - .valign(gtk4::Align::Center) - .build(); - remove.add_css_class("flat"); - let state_rm = state.clone(); - let id = entry.id.clone(); - let kind = entry.kind; - remove.connect_clicked(move |_| { - share_dialog_op( - &state_rm, - Request::RemoveShareEntry { - path: state_rm.rel.clone(), - id: id.clone(), - kind, - }, - "Access removed", - "Couldn't remove access", - None, - ); - }); - row.add_suffix(&remove); - - state.people.add(&row); - rows.push(row.upcast()); - } - *state.people_rows.borrow_mut() = rows; -} - -/// Rebuild the public-link section: an existing link (copy / remove) or a -/// create control. -fn repaint_share_link(state: &Rc, link: Option<&PublicLinkInfo>) { - for row in state.link_rows.borrow_mut().drain(..) { - state.link_group.remove(&row); - } - let mut rows: Vec = Vec::new(); - - match link { - Some(link) => { - let url = link.url.clone().unwrap_or_default(); - let subtitle = if link.has_password { - format!("Anyone with the link ({}) · password-protected", link.role) - } else { - format!("Anyone with the link ({})", link.role) - }; - let row = adw::ActionRow::builder() - .title(if url.is_empty() { "Public link" } else { &url }) - .subtitle(&subtitle) - .build(); - row.add_css_class("property"); - - if !url.is_empty() { - let copy = gtk4::Button::builder() - .icon_name("edit-copy-symbolic") - .tooltip_text("Copy link") - .valign(gtk4::Align::Center) - .build(); - copy.add_css_class("flat"); - let state_copy = state.clone(); - let url_copy = url.clone(); - copy.connect_clicked(move |btn| { - btn.clipboard().set_text(&url_copy); - toast(&state_copy.ui, "Link copied"); - }); - row.add_suffix(©); - } - - let remove = gtk4::Button::builder() - .icon_name("user-trash-symbolic") - .tooltip_text("Remove link") - .valign(gtk4::Align::Center) - .build(); - remove.add_css_class("flat"); - let state_rm = state.clone(); - let id = link.id.clone(); - remove.connect_clicked(move |_| { - share_dialog_op( - &state_rm, - Request::RemovePublicLink { - path: state_rm.rel.clone(), - id: id.clone(), - }, - "Public link removed", - "Couldn't remove the link", - None, - ); - }); - row.add_suffix(&remove); - - state.link_group.add(&row); - rows.push(row.upcast()); - } - None => { - let role_model = gtk4::StringList::new(&["Viewer", "Editor"]); - let role_drop = gtk4::DropDown::builder() - .model(&role_model) - .selected(0) - .valign(gtk4::Align::Center) - .build(); - let role_row = adw::ActionRow::builder().title("Link role").build(); - role_row.add_suffix(&role_drop); - let pw_row = adw::PasswordEntryRow::builder() - .title("Password (optional)") - .build(); - let create = gtk4::Button::builder() - .label("Create Public Link") - .halign(gtk4::Align::End) - .margin_top(6) - .build(); - create.add_css_class("suggested-action"); - let create_wrap = adw::PreferencesRow::builder() - .activatable(false) - .child(&create) - .build(); - - // Enter in the password field creates the link. - let btn = create.clone(); - pw_row.connect_entry_activated(move |_| btn.emit_clicked()); - - let state_c = state.clone(); - let pw_for = pw_row.clone(); - create.connect_clicked(move |_| { - let role = if role_drop.selected() == 1 { - "editor" - } else { - "viewer" - } - .to_string(); - let pw = pw_for.text().to_string(); - let password = if pw.is_empty() { None } else { Some(pw) }; - share_dialog_create_link(&state_c, role, password); - }); - - state.link_group.add(&role_row); - state.link_group.add(&pw_row); - state.link_group.add(&create_wrap); - rows.push(role_row.upcast()); - rows.push(pw_row.upcast()); - rows.push(create_wrap.upcast()); - } - } - *state.link_rows.borrow_mut() = rows; -} - -/// Create a public link, then reload the dialog so the copy/remove controls -/// replace the create form (and the freshly minted URL is shown). -fn share_dialog_create_link(state: &Rc, role: String, password: Option) { - let rx = spawn_request( - state.ui.dirs.control_socket(), - Request::CreatePublicLink { - path: state.rel.clone(), - role, - password, - expires: None, - }, - ); - let state = state.clone(); - glib::spawn_future_local(async move { - match rx.recv().await { - Ok(Ok(Response::PublicLink { .. })) => { - toast(&state.ui, "Public link created"); - share_dialog_reload(&state); - } - Ok(Ok(Response::Error { message })) => { - toast_error(&state.ui, "Couldn't create the link", &message) - } - _ => toast_error( - &state.ui, - "Couldn't create the link", - "The mount service didn't respond.", - ), - } - }); -} - -/// Run a Share-dialog mutation, then reload the dialog on success. `on_success` -/// runs an extra UI tweak (e.g. clearing the invite fields) before the reload. -fn share_dialog_op( - state: &Rc, - req: Request, - done: &'static str, - failed: &'static str, - on_success: Option>, -) { - let rx = spawn_request(state.ui.dirs.control_socket(), req); - let state = state.clone(); - glib::spawn_future_local(async move { - match rx.recv().await { - Ok(Ok(Response::Ok { .. })) => { - if let Some(cb) = on_success { - cb(); - } - toast(&state.ui, done); - share_dialog_reload(&state); - } - Ok(Ok(Response::Error { message })) => toast_error(&state.ui, failed, &message), - _ => { - // A role dropdown that failed is now out of sync with the server; - // reload to snap it back. - toast_error(&state.ui, failed, "The mount service didn't respond."); - share_dialog_reload(&state); - } - } - }); -} - -/// Uppercase the first character of a role word for read-only display. -fn capitalize(s: &str) -> String { - let mut chars = s.chars(); - match chars.next() { - Some(c) => c.to_uppercase().collect::() + chars.as_str(), - None => String::new(), - } -} - -/// One day-section of the photos timeline: a heading plus the photos captured -/// that day, in timeline order. Built from the flat [`Ui::gallery_model`] by -/// [`group_photos`] and rendered as one [`gtk4::ListView`] row. -struct PhotoGroup { - /// "Today", "Yesterday", or e.g. "3 June 2026". - heading: String, - photos: Vec, -} - -/// The widgets [`build_gallery_page`] hands back to [`build_window`]. -struct GalleryWidgets { - /// Flat, newest-first list of every loaded photo. Backs the lightbox's - /// prev/next navigation; the visible sections are derived from it. - model: gio::ListStore, - /// Day sections rendered by the ListView, derived from `model`. - groups: gio::ListStore, - /// Swaps between the timeline and the empty/loading/error status page. - content: gtk4::Stack, - status: adw::StatusPage, - subtitle: gtk4::Label, - more: gtk4::Button, - list: gtk4::ListView, - scroll: gtk4::ScrolledWindow, - retry: gtk4::Button, - upload: gtk4::Button, - refresh: gtk4::Button, - /// The All / Photos / Videos / Raw filter toggles, in that order (index maps - /// to [`kind_for_tab`]). - tabs: [gtk4::ToggleButton; 4], - /// The date-jump dropdown, populated with the timeline's months. - dates: gtk4::DropDown, -} - -/// The Photos page: a [`gtk4::ListView`] of day sections, each a heading over -/// that day's photos laid out as justified rows (see [`justify_rows`]) — every -/// photo at its own aspect ratio, every row filled edge to edge. -/// -/// A ListView of sections rather than one flat GridView because GTK's grid has no -/// row headers and forces square cells: the justified rows and the date headings -/// both need per-row structure, and the ListView only realises the sections on -/// screen, which is what keeps a 10,000-photo timeline cheap. The factory is -/// installed by [`wire_gallery`], which has the [`Ui`] the tiles need (zoom -/// level, thumbnail cache, click-to-open). -fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { - let model = gio::ListStore::new::(); - let groups = gio::ListStore::new::(); - - let selection = gtk4::NoSelection::new(Some(groups.clone())); - let list = gtk4::ListView::builder() - .model(&selection) - .single_click_activate(false) - .build(); - list.add_css_class("gallery-sections"); - - // Shown only when a load failed because the mount is down; restarts it. - let retry = gtk4::Button::builder() - .label("Retry") - .halign(gtk4::Align::Center) - .build(); - retry.add_css_class("pill"); - retry.add_css_class("suggested-action"); - retry.set_visible(false); - - let status = adw::StatusPage::builder() - .icon_name("image-x-generic-symbolic") - .vexpand(true) - .child(&retry) - .build(); - status.add_css_class("compact"); - - // Kept as an explicit fallback: the timeline also pages itself in as the - // scroll nears the bottom (see [`wire_gallery`]), so reaching this button at - // all is unusual. - let more = gtk4::Button::builder() - .label("Load more") - .halign(gtk4::Align::Center) - .build(); - more.add_css_class("pill"); - more.set_visible(false); - - // Horizontal scrolling is never wanted: rows are justified to the viewport - // width, and a stray hscrollbar would fight the layout. - let scroll = gtk4::ScrolledWindow::builder() - .vexpand(true) - .hscrollbar_policy(gtk4::PolicyType::Never) - .child(&list) - .build(); - - let title_label = gtk4::Label::builder() - .label("Photos") - .halign(gtk4::Align::Start) - .build(); - title_label.add_css_class("title-2"); - - let subtitle = gtk4::Label::builder() - .halign(gtk4::Align::Start) - .visible(false) - .build(); - subtitle.add_css_class("dim-label"); - subtitle.add_css_class("caption"); - - let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 2); - titles.set_hexpand(true); - titles.append(&title_label); - titles.append(&subtitle); - - let upload = gtk4::Button::builder() - .label("Upload") - .icon_name("list-add-symbolic") - .valign(gtk4::Align::Center) - .build(); - upload.add_css_class("pill"); - upload.add_css_class("suggested-action"); - let refresh = refresh_button(); - - let header_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); - header_box.append(&titles); - header_box.append(&refresh); - header_box.append(&upload); - - // All / Photos / Videos / Raw filter. Linked toggles acting as one segmented - // control: exactly one is active, and flipping it reloads the timeline - // filtered to that kind (wired in [`wire_gallery`]). Labels gain live counts - // once a page lands. - let tab_labels = ["All", "Photos", "Videos", "Raw"]; - let tabs: [gtk4::ToggleButton; 4] = std::array::from_fn(|i| { - gtk4::ToggleButton::builder() - .label(tab_labels[i]) - .active(i == 0) - .build() - }); - // Group the toggles so they behave as a radio set: chaining each to the first - // is what GTK turns into mutual exclusion. - for btn in &tabs[1..] { - btn.set_group(Some(&tabs[0])); - } - let tab_group = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); - tab_group.add_css_class("linked"); - for btn in &tabs { - btn.add_css_class("pill"); - tab_group.append(btn); - } - - // Date jump: "All dates" plus a row per month, filled in once the timeline's - // months are known (see [`refresh_photo_months`]). Pushed to the far end of - // the filter row, opposite the kind toggles. - let dates = gtk4::DropDown::from_strings(&["All dates"]); - dates.add_css_class("pill"); - dates.set_tooltip_text(Some("Jump to a month")); - - let filter_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); - filter_bar.append(&tab_group); - let spacer = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); - spacer.set_hexpand(true); - filter_bar.append(&spacer); - filter_bar.append(&dates); - - // The timeline (plus its pager) or the status page, never both. - let timeline = gtk4::Box::new(gtk4::Orientation::Vertical, 12); - timeline.append(&scroll); - timeline.append(&more); - - let content = gtk4::Stack::new(); - content.set_vexpand(true); - content.set_transition_type(gtk4::StackTransitionType::Crossfade); - content.add_named(&timeline, Some("timeline")); - content.add_named(&status, Some("status")); - - let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12); - inner.set_margin_top(12); - inner.set_margin_bottom(12); - inner.set_margin_start(12); - inner.set_margin_end(12); - inner.append(&header_box); - inner.append(&filter_bar); - inner.append(&content); - - ( - inner.upcast(), - GalleryWidgets { - model, - groups, - content, - status, - subtitle, - more, - list, - scroll, - retry, - upload, - refresh, - tabs, - dates, - }, - ) -} - -/// The [`PhotoKind`] filter a gallery tab index selects: index 0 is All (no -/// filter), then Photos / Videos / Raw. Index-aligned with the toggle array. -fn kind_for_tab(index: usize) -> Option { - match index { - 1 => Some(PhotoKind::Photo), - 2 => Some(PhotoKind::Video), - 3 => Some(PhotoKind::Raw), - _ => None, - } -} - -/// The `[from, to)` epoch-second window of a local calendar month, or `None` if -/// the date is somehow unrepresentable. Computed with glib so month rollover and -/// the local timezone (matching the daemon's month buckets) are handled for us. -fn month_range(year: i32, month: i32) -> Option<(i64, i64)> { - let start = glib::DateTime::from_local(year, month, 1, 0, 0, 0.0).ok()?; - let end = start.add_months(1).ok()?; - Some((start.to_unix(), end.to_unix())) -} - -/// English month names, indexed 1..=12. -const MONTH_NAMES: [&str; 12] = [ - "January", "February", "March", "April", "May", "June", "July", "August", "September", - "October", "November", "December", -]; - -/// Rebuild the date-jump dropdown for the active kind: ask the daemon which -/// months the timeline spans and turn them into "Month YYYY (count)" rows, each -/// remembering the window it jumps to. Resets the selection to "All dates" — the -/// caller pairs this with a fresh timeline load. Off the UI thread; a failure -/// just leaves the dropdown as it was. -fn refresh_photo_months(ui: &Rc) { - let rx = spawn_request( - ui.dirs.control_socket(), - Request::PhotoMonths { - kind: ui.gallery_kind.get(), - }, - ); - let ui = ui.clone(); - glib::spawn_future_local(async move { - let Ok(Ok(Response::PhotoMonths { months })) = rx.recv().await else { - return; - }; - let mut labels = vec!["All dates".to_string()]; - let mut ranges: Vec> = vec![None]; - for m in months { - let name = MONTH_NAMES.get((m.month - 1) as usize).copied().unwrap_or("?"); - labels.push(format!("{name} {} ({})", m.year, m.count)); - ranges.push(month_range(m.year, m.month)); - } - let label_refs: Vec<&str> = labels.iter().map(|s| s.as_str()).collect(); - - // Repopulating resets the selection to 0, which would otherwise fire the - // handler and reload; suppress that — the caller is already reloading. - ui.gallery_date_suppress.set(true); - ui.gallery_dates - .set_model(Some(>k4::StringList::new(&label_refs))); - ui.gallery_dates.set_selected(0); - *ui.gallery_date_ranges.borrow_mut() = ranges; - ui.gallery_date_suppress.set(false); - }); -} - -/// Label the filter toggles with the whole-timeline `(photos, videos, raw)` -/// counts, so a glance shows how much sits behind each tab. A tab with nothing -/// behind it is disabled — you can't filter to an empty set — but the currently -/// selected one stays clickable so you can always switch back off it. -fn update_gallery_tabs(ui: &Rc, counts: (usize, usize, usize)) { - let (photos, videos, raw) = counts; - let totals = [photos + videos + raw, photos, videos, raw]; - for (index, tab) in ui.gallery_tabs.iter().enumerate() { - let name = ["All", "Photos", "Videos", "Raw"][index]; - let n = totals[index]; - tab.set_label(&format!("{name} {n}")); - tab.set_sensitive(n > 0 || tab.is_active()); - } -} - -/// Wire the gallery: install the section factory, the zoom gestures, the pager -/// and the upload button. Activating a thumbnail downloads the photo and opens it -/// in the in-app lightbox. -fn wire_gallery(ui: &Rc, list: >k4::ListView, scroll: >k4::ScrolledWindow) { - let factory = gtk4::SignalListItemFactory::new(); - factory.connect_setup(|_, item| { - let item = item.downcast_ref::().unwrap(); - let section = gtk4::Box::new(gtk4::Orientation::Vertical, 8); - section.set_margin_bottom(16); - let heading = gtk4::Label::builder().halign(gtk4::Align::Start).build(); - heading.add_css_class("heading"); - heading.add_css_class("gallery-day"); - section.append(&heading); - item.set_child(Some(§ion)); - item.set_activatable(false); - }); - - let ui_bind = ui.clone(); - factory.connect_bind(move |_, item| { - let item = item.downcast_ref::().unwrap(); - let section = item.child().and_downcast::().unwrap(); - let obj = item.item().and_downcast::().unwrap(); - let group = obj.borrow::(); - - let heading = section.first_child().and_downcast::().unwrap(); - heading.set_label(&group.heading); - - fill_section(&ui_bind, §ion, &group.photos); - // Remember the realised section so a learned aspect ratio or a resize can - // re-justify it in place, without rebuilding the ListStore (which would - // yank the scroll position back to the top). - ui_bind - .gallery_bound - .borrow_mut() - .insert(item.position(), section); - }); - - // ListView recycles section widgets, so a scrolled-away day must give up its - // claim on the widgets — otherwise a thumbnail landing late would paint into - // a tile that now shows a different day. - let ui_unbind = ui.clone(); - factory.connect_unbind(move |_, item| { - let item = item.downcast_ref::().unwrap(); - ui_unbind - .gallery_bound - .borrow_mut() - .remove(&item.position()); - if let Some(obj) = item.item().and_downcast::() { - let group = obj.borrow::(); - let mut wanted = ui_unbind.thumb_wanted.borrow_mut(); - for photo in &group.photos { - wanted.remove(&photo.uid); - } - } - }); - list.set_factory(Some(&factory)); - - // Rows are justified to the content width, so a resize re-justifies whatever - // is on screen (offscreen sections pick the new width up when they bind). - let ui_width = ui.clone(); - list.connect_notify_local(Some("width"), move |list, _| { - let width = list.width(); - if width > 0 && width != ui_width.gallery_width.get() { - ui_width.gallery_width.set(width); - schedule_relayout(&ui_width); - } - }); - - // Page the timeline in as the scroll nears the end, so "load more" is a - // fallback button rather than something the user has to hunt for. - let ui_scroll = ui.clone(); - scroll.vadjustment().connect_value_changed(move |adj| { - let near_end = adj.value() + adj.page_size() >= adj.upper() - adj.page_size() * 0.5; - if near_end && ui_scroll.gallery_more.is_visible() && ui_scroll.gallery_more.is_sensitive() - { - load_gallery(&ui_scroll, true); - } - }); - - // Ctrl+scroll zoom. Capture phase so the ScrolledWindow doesn't eat the event - // and scroll the page out from under the gesture. - let zoom_scroll = gtk4::EventControllerScroll::new(gtk4::EventControllerScrollFlags::VERTICAL); - zoom_scroll.set_propagation_phase(gtk4::PropagationPhase::Capture); - let ui_zoom = ui.clone(); - zoom_scroll.connect_scroll(move |controller, _dx, dy| { - if !controller - .current_event_state() - .contains(gtk4::gdk::ModifierType::CONTROL_MASK) - || dy == 0.0 - { - return glib::Propagation::Proceed; - } - // Scroll up (negative dy) zooms in, i.e. bigger tiles. - zoom_gallery(&ui_zoom, if dy < 0.0 { TILE_STEP } else { -TILE_STEP }); - glib::Propagation::Stop - }); - scroll.add_controller(zoom_scroll); - - // Ctrl+plus / Ctrl+minus / Ctrl+0, the keyboard equivalents. - let zoom_keys = gtk4::EventControllerKey::new(); - let ui_keys = ui.clone(); - zoom_keys.connect_key_pressed(move |_, key, _code, state| { - if !state.contains(gtk4::gdk::ModifierType::CONTROL_MASK) { - return glib::Propagation::Proceed; - } - match key.name().as_deref() { - Some("plus" | "equal" | "KP_Add") => zoom_gallery(&ui_keys, TILE_STEP), - Some("minus" | "KP_Subtract") => zoom_gallery(&ui_keys, -TILE_STEP), - Some("0" | "KP_0") => set_gallery_tile(&ui_keys, TILE_DEFAULT), - _ => return glib::Propagation::Proceed, - } - glib::Propagation::Stop - }); - list.add_controller(zoom_keys); - - let ui_more = ui.clone(); - ui.gallery_more.clone().connect_clicked(move |_| { - load_gallery(&ui_more, true); - }); - - // Filter toggles: flipping to a tab reloads the timeline filtered to that - // kind. Only the button being switched *on* acts — the group also fires a - // `toggled` for the one switching off, which this skips — and a redundant - // toggle to the already-current kind is a no-op. - for (index, tab) in ui.gallery_tabs.iter().enumerate() { - let ui_tab = ui.clone(); - tab.connect_toggled(move |btn| { - if !btn.is_active() { - return; - } - let kind = kind_for_tab(index); - if ui_tab.gallery_kind.get() == kind { - return; - } - ui_tab.gallery_kind.set(kind); - // A different kind has a different set of months; clearing the active - // window makes the reload rebuild the date jump for the new kind. - ui_tab.gallery_range.set(None); - load_gallery(&ui_tab, false); - }); - } - - // Date jump: selecting a month loads that window; "All dates" (row 0) clears - // it. Skipped while the model is being repopulated (see `gallery_date_suppress`). - let ui_dates = ui.clone(); - ui.gallery_dates.connect_selected_notify(move |dd| { - if ui_dates.gallery_date_suppress.get() { - return; - } - let range = ui_dates - .gallery_date_ranges - .borrow() - .get(dd.selected() as usize) - .copied() - .flatten(); - if ui_dates.gallery_range.get() == range { - return; - } - ui_dates.gallery_range.set(range); - load_gallery(&ui_dates, false); - }); - - let ui_upload = ui.clone(); - ui.gallery_upload.connect_clicked(move |_| { - let dialog = gtk4::FileDialog::builder() - .title("Select Photo to Upload") - .build(); - - let filter = gtk4::FileFilter::new(); - filter.set_name(Some("Images")); - filter.add_mime_type("image/*"); - let filters = gio::ListStore::new::(); - filters.append(&filter); - dialog.set_filters(Some(&filters)); - - let ui = ui_upload.clone(); - 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); - - 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); - toast(&ui_clone, "Photo uploaded"); - } - Ok(Ok(Response::Error { message })) => { - toast_error(&ui_clone, "Couldn't upload photo", &message); - } - _ => { - toast_error( - &ui_clone, - "Couldn't upload photo", - "The mount service didn't respond.", - ); - } - } - }); - } - }); - }); -} - -/// One tile in a justified row: the photo, and the pixel size it was justified to. -struct Tile { - photo: PhotoItem, - width: i32, - height: i32, -} - -/// Break one day's photos into rows that each fill `width` exactly, at heights -/// near [`Ui::gallery_tile`] — the layout every modern photo gallery uses, and -/// the reason nothing here has to be cropped: each tile keeps its own aspect -/// ratio, and it is the row *height* that flexes to make the widths add up. -/// -/// Greedy, one pass: keep adding photos to the row: the more photos share it, the -/// shorter it has to be to fit. The moment that height drops to the target, the -/// row is as full as it should be and is emitted. The trailing row keeps the -/// target height instead of being stretched, so a day with three photos doesn't -/// blow them up to fill a screen-wide row. -/// -/// Photos whose thumbnail hasn't been decoded yet are laid out at -/// [`RATIO_UNKNOWN`]; [`Ui::store_texture`] reports the real ratio when it lands -/// and the section is re-justified in place. -fn justify_rows(ui: &Rc, photos: &[PhotoItem], width: i32) -> Vec> { - let ratios: Vec = photos.iter().map(|photo| ui.ratio(&photo.uid)).collect(); - let target = f64::from(ui.gallery_tile.get()); - let plan = plan_rows(&ratios, target, f64::from(width)); - - let mut photos = photos.iter(); - plan.into_iter() - .map(|row| { - row.into_iter() - .filter_map(|(width, height)| { - photos.next().map(|photo| Tile { - photo: photo.clone(), - width, - height, - }) - }) - .collect() - }) - .collect() -} - -/// The row-packing math behind [`justify_rows`], over nothing but aspect ratios: -/// takes each photo's ratio (w/h) in order and returns the pixel `(width, -/// height)` of every tile, grouped into rows. -fn plan_rows(ratios: &[f64], target: f64, width: f64) -> Vec> { - let avail = width.max(f64::from(TILE_MIN)); - let gap = f64::from(TILE_GAP); - - // The height a row of `n` photos with `sum_ratio` total ratio must take for - // its widths (plus gaps) to add up to exactly `avail`. - let row_height = |sum_ratio: f64, n: usize| { - let gaps = gap * (n.saturating_sub(1)) as f64; - (avail - gaps) / sum_ratio - }; - - let mut rows: Vec> = Vec::new(); - let mut row: Vec = Vec::new(); - let mut sum_ratio = 0.0; - - for ratio in ratios { - let ratio = ratio.clamp(RATIO_MIN, RATIO_MAX); - row.push(ratio); - sum_ratio += ratio; - - let height = row_height(sum_ratio, row.len()); - if height <= target { - rows.push(size_row(&row, height, avail, true)); - row.clear(); - sum_ratio = 0.0; - } - } - if !row.is_empty() { - let height = row_height(sum_ratio, row.len()).min(target); - rows.push(size_row(&row, height, avail, false)); - } - rows -} - -/// Size one row's tiles at `height`. A `justified` row is nudged so its widths -/// plus gaps hit `avail` exactly — rounding each width independently leaves a -/// few px of ragged right edge, so the last tile absorbs the remainder. -fn size_row(ratios: &[f64], height: f64, avail: f64, justified: bool) -> Vec<(i32, i32)> { - let height = height.round().max(1.0); - let mut sizes: Vec<(i32, i32)> = ratios - .iter() - .map(|ratio| ((ratio * height).round().max(1.0) as i32, height as i32)) - .collect(); - - if justified { - let gaps = TILE_GAP * (sizes.len().saturating_sub(1)) as i32; - let used: i32 = sizes.iter().rev().skip(1).map(|(width, _)| *width).sum(); - if let Some(last) = sizes.last_mut() { - last.0 = (avail as i32 - gaps - used).max(1); - } - } - sizes -} - -/// (Re)build a bound day-section's tiles: justify this day's photos to the -/// current content width and hand each tile whatever thumbnail is already in -/// memory, queueing the rest. Replaces the section's rows in place, leaving the -/// heading — so a re-justify never touches the ListView's model or scroll. -fn fill_section(ui: &Rc, section: >k4::Box, photos: &[PhotoItem]) { - let Some(heading) = section.first_child() else { - return; - }; - while let Some(old) = heading.next_sibling() { - section.remove(&old); - } - - let width = gallery_width(ui); - let rows = gtk4::Box::new(gtk4::Orientation::Vertical, TILE_GAP); - for row in justify_rows(ui, photos, width) { - let row_box = gtk4::Box::new(gtk4::Orientation::Horizontal, TILE_GAP); - for tile in row { - row_box.append(&photo_tile(ui, tile)); - } - rows.append(&row_box); - } - section.append(&rows); - schedule_thumbs(ui); -} - -/// The width justified rows are laid out to: the ListView's own width, less a -/// couple of px so a rounding error can't push a row into a horizontal overflow. -/// Falls back to a sane guess before the first allocation. -fn gallery_width(ui: &Rc) -> i32 { - match ui.gallery_width.get() { - 0 => 900, - w => (w - 2).max(TILE_MIN), - } -} - -/// One photo tile: a fixed-size button wrapping the thumbnail, with the capture -/// time revealed on hover over a bottom scrim. A button (rather than a bare -/// picture) so the tile is focusable, keyboard-activatable and gets hover feedback -/// for free. -/// -/// The picture sits in an overlay over a placeholder, so a tile is never a hole: -/// until the thumbnail lands it shows a dim card, and a photo that can never have -/// one keeps an image glyph instead of an empty rectangle. -fn photo_tile(ui: &Rc, tile: Tile) -> gtk4::Button { - let picture = gtk4::Picture::builder() - // The tile is exactly the thumbnail's own aspect ratio, so Cover scales - // it and crops nothing; it only bites during the brief window where the - // ratio is still a guess, and the re-justify then fixes the tile. - .content_fit(gtk4::ContentFit::Cover) - .can_shrink(true) - .build(); - - let placeholder = gtk4::Image::builder() - .icon_name("image-x-generic-symbolic") - .pixel_size(24) - .halign(gtk4::Align::Center) - .valign(gtk4::Align::Center) - .build(); - placeholder.add_css_class("photo-placeholder"); - - // The capture time, on a gradient that only exists while the pointer is over - // the tile — legible over any photo, invisible the rest of the time. - let caption = gtk4::Label::builder() - // Fill horizontally so the scrim spans the tile; the text itself stays - // left-aligned inside it. - .halign(gtk4::Align::Fill) - .valign(gtk4::Align::End) - .xalign(0.0) - .label(short_capture_time(tile.photo.capture_time)) - .ellipsize(gtk4::pango::EllipsizeMode::End) - .build(); - caption.add_css_class("photo-caption"); - - let overlay = gtk4::Overlay::new(); - overlay.set_child(Some(&placeholder)); - overlay.add_overlay(&picture); - overlay.add_overlay(&caption); - - // A video reads as a video at a glance: a play glyph centred over the poster - // thumbnail. Kept above the caption scrim so it stays legible on hover. - let is_video = tile.photo.kind == PhotoKind::Video; - if is_video { - let badge = gtk4::Image::builder() - .icon_name("media-playback-start-symbolic") - .pixel_size(28) - .halign(gtk4::Align::Center) - .valign(gtk4::Align::Center) - .build(); - badge.add_css_class("photo-video-badge"); - overlay.add_overlay(&badge); - } - - let button = gtk4::Button::builder() - .child(&overlay) - .width_request(tile.width) - .height_request(tile.height) - .tooltip_text(format_capture_time(tile.photo.capture_time)) - .build(); - button.add_css_class("photo-tile"); - button.add_css_class("flat"); - // Clip the thumbnail to the tile's rounded corners. - button.set_overflow(gtk4::Overflow::Hidden); - - want_thumb(ui, &tile.photo, &picture); - - // A still opens in the in-app lightbox; a video can't render there, so it - // downloads and hands off to an external player instead. - let ui_open = ui.clone(); - let uid = tile.photo.uid.clone(); - button.connect_clicked(move |_| { - if is_video { - play_video(&ui_open, uid.clone()); - } else { - open_photo_viewer(&ui_open, uid.clone()); - } - }); - button -} - -/// Give `picture` its thumbnail: straight from the texture cache when it's there, -/// otherwise register the tile as waiting and get the thumbnail moving — decoding -/// it if the daemon already had it cached on disk, or asking the daemon for it. -/// -/// This is what makes the gallery on-demand: only tiles the ListView actually -/// realises ever ask for an image. -fn want_thumb(ui: &Rc, photo: &PhotoItem, picture: >k4::Picture) { - if let Some(texture) = ui.photo_tex.borrow().get(&photo.uid) { - picture.set_paintable(Some(texture)); - return; - } - // No thumbnail will ever come for this one — not from the server, and not - // from the daemon's own scaling of the file. The tile keeps its placeholder - // glyph, and stays clickable: the full photo may still open fine. - if ui.photo_nothumb.borrow().contains(&photo.uid) { - return; - } - - ui.thumb_wanted - .borrow_mut() - .insert(photo.uid.clone(), picture.clone()); - - match photo.thumb_path.as_deref() { - Some(path) => { - ui.decode_queue - .borrow_mut() - .push_back((photo.uid.clone(), path.to_string())); - schedule_decode(ui); - } - None => { - let mut queue = ui.thumb_queue.borrow_mut(); - if !queue.contains(&photo.uid) { - queue.push_back(photo.uid.clone()); - } - } - } -} - -/// Come back for thumbnails the daemon is still generating — it is downloading -/// each photo's full file to scale it, which takes far longer than a batch. The -/// tiles keep their placeholder until then, and a tile that has scrolled away is -/// dropped by [`flush_thumbs`] like any other queued uid. -fn retry_pending_thumbs(ui: &Rc, uids: Vec) { - let ui = ui.clone(); - glib::timeout_add_local_once(THUMB_RETRY, move || { - { - let mut queue = ui.thumb_queue.borrow_mut(); - for uid in uids { - if !queue.contains(&uid) { - queue.push_back(uid); - } - } - } - schedule_thumbs(&ui); - }); -} - -/// Ask the daemon for the queued thumbnails after a short pause, so a fast scroll -/// coalesces into one batch per settle rather than one per row it flew past. -fn schedule_thumbs(ui: &Rc) { - if ui.thumb_queue.borrow().is_empty() || ui.thumb_inflight.get() { - return; - } - if let Some(id) = ui.thumb_source.borrow_mut().take() { - id.remove(); - } - let ui_flush = ui.clone(); - let source = glib::timeout_add_local_once(THUMB_DEBOUNCE, move || { - ui_flush.thumb_source.borrow_mut().take(); - flush_thumbs(&ui_flush); - }); - *ui.thumb_source.borrow_mut() = Some(source); -} - -/// Send one [`Request::PhotoThumbs`] batch for the tiles still on screen. Queued -/// uids whose tile has scrolled away are dropped rather than fetched: the point -/// of the batch is what the user is looking at *now*. -fn flush_thumbs(ui: &Rc) { - if ui.thumb_inflight.get() { - return; - } - let uids: Vec = { - let mut queue = ui.thumb_queue.borrow_mut(); - let wanted = ui.thumb_wanted.borrow(); - let mut batch = Vec::new(); - while batch.len() < THUMB_BATCH { - let Some(uid) = queue.pop_front() else { break }; - if wanted.contains_key(&uid) { - batch.push(uid); - } - } - batch - }; - if uids.is_empty() { - return; - } - - ui.thumb_inflight.set(true); - let rx = spawn_request(ui.dirs.control_socket(), Request::PhotoThumbs { uids }); - let ui = ui.clone(); - glib::spawn_future_local(async move { - let result = rx.recv().await; - ui.thumb_inflight.set(false); - match result { - Ok(Ok(Response::Thumbs { items })) => { - let mut decode = ui.decode_queue.borrow_mut(); - let mut nothumb = ui.photo_nothumb.borrow_mut(); - let mut pending = Vec::new(); - for item in items { - match item.path { - Some(path) => decode.push_back((item.uid, path)), - // The daemon is making this one itself, from the photo's - // full file — that takes a download, so come back for it. - None if item.pending => pending.push(item.uid), - // No thumbnail exists and none can be made: remember that, - // so scrolling past the tile doesn't re-ask forever. - None => { - nothumb.insert(item.uid); - } - } - } - drop((decode, nothumb)); - schedule_decode(&ui); - if !pending.is_empty() { - retry_pending_thumbs(&ui, pending); - } - } - // A thumbnail that doesn't arrive is not worth a toast — the tile just - // stays a placeholder, and the next scroll past it tries again. - Ok(Ok(Response::Error { message })) => { - tracing::debug!("photo thumbs failed: {message}") - } - Ok(Ok(_)) | Ok(Err(_)) | Err(_) => tracing::debug!("photo thumbs: no reply"), - } - // Whatever the batch did, more tiles may have queued up behind it. - schedule_thumbs(&ui); - }); -} - -/// Decode queued thumbnails into textures on an idle callback, a few per pass, so -/// a big batch fills in progressively instead of freezing the scroll for the -/// length of the whole decode. -fn schedule_decode(ui: &Rc) { - if ui.decode_idle.get() || ui.decode_queue.borrow().is_empty() { - return; - } - ui.decode_idle.set(true); - - let ui = ui.clone(); - glib::idle_add_local(move || { - let batch: Vec<(String, String)> = { - let mut queue = ui.decode_queue.borrow_mut(); - (0..4).filter_map(|_| queue.pop_front()).collect() - }; - let mut relayout = false; - for (uid, path) in batch { - let texture = match gtk4::gdk::Texture::from_filename(&path) { - Ok(texture) => texture, - Err(e) => { - tracing::debug!("cannot decode thumbnail {path}: {e}"); - ui.photo_nothumb.borrow_mut().insert(uid); - continue; - } - }; - // A ratio we hadn't seen means the tile was sized against a guess. - relayout |= ui.store_texture(&uid, texture.clone()); - if let Some(picture) = ui.thumb_wanted.borrow_mut().remove(&uid) { - picture.set_paintable(Some(&texture)); - } - } - if relayout { - schedule_relayout(&ui); - } - - if ui.decode_queue.borrow().is_empty() { - ui.decode_idle.set(false); - ui.save_ratios(); - return glib::ControlFlow::Break; - } - glib::ControlFlow::Continue - }); -} - -/// Re-justify the sections on screen shortly. Debounced, because the triggers -/// (a window resize, a zoom step, a burst of decoded thumbnails) all arrive in -/// floods and only the final state matters. -fn schedule_relayout(ui: &Rc) { - if let Some(id) = ui.relayout_source.borrow_mut().take() { - id.remove(); - } - let ui_relayout = ui.clone(); - let source = glib::timeout_add_local_once(RELAYOUT_DEBOUNCE, move || { - ui_relayout.relayout_source.borrow_mut().take(); - relayout_gallery(&ui_relayout); - }); - *ui.relayout_source.borrow_mut() = Some(source); -} - -/// Rebuild the tiles of the day sections currently on screen, at the current -/// width, zoom and set of known aspect ratios. Sections that are *not* realised -/// need no work: they justify themselves against the current state when the -/// ListView binds them. -fn relayout_gallery(ui: &Rc) { - let bound: Vec<(u32, gtk4::Box)> = ui - .gallery_bound - .borrow() - .iter() - .map(|(pos, section)| (*pos, section.clone())) - .collect(); - for (pos, section) in bound { - let Some(obj) = ui.gallery_groups.item(pos) else { - continue; - }; - let Some(boxed) = obj.downcast_ref::() else { - continue; - }; - let photos = boxed.borrow::().photos.clone(); - fill_section(ui, §ion, &photos); - } -} - -/// Step the row height by `delta` px and re-justify, clamped to the zoom range. -fn zoom_gallery(ui: &Rc, delta: i32) { - set_gallery_tile(ui, ui.gallery_tile.get() + delta); -} - -/// Set the row height (clamped) and re-justify the visible sections at it. -fn set_gallery_tile(ui: &Rc, tile: i32) { - let tile = tile.clamp(TILE_MIN, TILE_MAX); - if tile == ui.gallery_tile.get() { - return; - } - ui.gallery_tile.set(tile); - schedule_relayout(ui); -} - -/// Rebuild the day sections from the flat photo model. The timeline arrives -/// newest-first, so photos of the same day are already contiguous — one pass -/// splits them. -/// -/// The groups are diffed into the existing store rather than replacing it: a -/// "load more" only really changes the last day (the one the new page continues) -/// and appends after it, and clearing the store instead would scroll the user -/// back to the top of the timeline at the exact moment they asked for more. -fn repaint_gallery(ui: &Rc) { - let groups = group_photos(&ui.gallery_model); - let store = &ui.gallery_groups; - - for (i, group) in groups.iter().enumerate() { - let i = i as u32; - let unchanged = store - .item(i) - .and_downcast::() - .is_some_and(|old| { - let old = old.borrow::(); - old.heading == group.heading && old.photos.len() == group.photos.len() - }); - if unchanged { - continue; - } - let boxed = BoxedAnyObject::new(PhotoGroup { - heading: group.heading.clone(), - photos: group.photos.clone(), - }); - if i < store.n_items() { - store.splice(i, 1, &[boxed]); - } else { - store.append(&boxed); - } - } - // Photos only ever get appended, so a shorter model means a fresh load. - if store.n_items() > groups.len() as u32 { - let len = groups.len() as u32; - store.splice(len, store.n_items() - len, &[] as &[BoxedAnyObject]); - } - - let count = ui.gallery_model.n_items(); - ui.gallery_subtitle.set_visible(count > 0); - // The noun tracks the active filter, so a Videos tab doesn't count "photos". - let (one, many) = match ui.gallery_kind.get() { - Some(PhotoKind::Video) => ("video", "videos"), - Some(PhotoKind::Raw) => ("raw photo", "raw photos"), - _ => ("photo", "photos"), - }; - ui.gallery_subtitle.set_label(&match count { - 1 => format!("1 {one}"), - n => format!("{n} {many}"), - }); -} - -fn group_photos(model: &gio::ListStore) -> Vec { - let mut groups: Vec = Vec::new(); - for i in 0..model.n_items() { - let Some(obj) = model.item(i) else { continue }; - let Some(boxed) = obj.downcast_ref::() else { - continue; - }; - let photo = boxed.borrow::().clone(); - let heading = day_heading(photo.capture_time); - match groups.last_mut() { - Some(group) if group.heading == heading => group.photos.push(photo), - _ => groups.push(PhotoGroup { - heading, - photos: vec![photo], - }), - } - } - groups -} - -/// Section heading for a capture time: "Today", "Yesterday", or the local date. -fn day_heading(secs: i64) -> String { - let Ok(date) = glib::DateTime::from_unix_local(secs) else { - return "Unknown date".into(); - }; - let same_day = |other: &glib::DateTime| { - other.year() == date.year() - && other.month() == date.month() - && other.day_of_month() == date.day_of_month() - }; - if let Ok(now) = glib::DateTime::now_local() { - if same_day(&now) { - return "Today".into(); - } - if let Ok(yesterday) = glib::DateTime::from_unix_local(now.to_unix() - 86_400) - && same_day(&yesterday) - { - return "Yesterday".into(); - } - } - date.format("%-d %B %Y") - .map(|s| s.to_string()) - .unwrap_or_else(|_| "Unknown date".into()) -} - -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); - } - } - None -} - -fn format_capture_time(secs: i64) -> String { - let date = glib::DateTime::from_unix_local(secs); - match date { - Ok(d) => match d.format("%Y-%m-%d %H:%M:%S") { - Ok(s) => s.to_string(), - Err(_) => "Unknown Date".to_string(), - }, - Err(_) => "Unknown Date".to_string(), - } -} - -/// The capture time as a tile caption: the clock time alone, since the day is -/// already the section heading right above it. -fn short_capture_time(secs: i64) -> String { - glib::DateTime::from_unix_local(secs) - .and_then(|d| d.format("%H:%M")) - .map(|s| s.to_string()) - .unwrap_or_default() -} - -/// The lightbox's mutable parts, shared by [`load_photo`], [`navigate_photo`] and -/// the button/key handlers so each one takes a single handle instead of a dozen -/// widget arguments. -struct Viewer { - picture: gtk4::Picture, - spinner: gtk4::Spinner, - status: gtk4::Label, - title: gtk4::Label, - counter: gtk4::Label, - prev: gtk4::Button, - next: gtk4::Button, - /// Details drawer: the toggle that reveals it, the rows it fills in, and the - /// "Show on map" button (hidden when the photo carries no GPS tags). - info_toggle: gtk4::ToggleButton, - info_revealer: gtk4::Revealer, - info_rows: gtk4::Box, - info_map: gtk4::Button, - /// Coordinates behind `info_map`, once a photo with GPS tags is shown. - coords: RefCell>, - /// uid of the photo currently on screen. - uid: RefCell, - /// On-disk path of the full-size photo, once it has been downloaded. - path: RefCell>, - /// True while the full-size photo is still downloading and what's on screen is - /// the upscaled thumbnail — a click-outside-to-close hit test has to size the - /// image from the *thumbnail's* ratio in that window, but more importantly a - /// late reply for a photo the user has already navigated away from must not - /// overwrite the new one. Guarded by comparing against `uid`. - loading: Cell, -} - -/// Camera/exposure/location facts pulled from a photo's own EXIF tags, as -/// label/value pairs for the details drawer. -struct ExifInfo { - /// `("Camera", "Apple iPhone 15")` and friends; empty when the file has no - /// EXIF at all, which is normal for screenshots and re-encoded images. - fields: Vec<(&'static str, String)>, - /// Decimal degrees, if the photo is geotagged. - coords: Option<(f64, f64)>, -} - -/// Show the photo behind `uid`: paint its (already cached) thumbnail immediately -/// so the lightbox never opens on a blank screen, ask the daemon for the -/// full-size file, and swap it in — plus its EXIF — when it lands. -fn load_photo(ui: &Rc, viewer: &Rc, uid: String) { - viewer.spinner.set_visible(true); - viewer.spinner.start(); - viewer.status.set_visible(false); - viewer.loading.set(true); - *viewer.path.borrow_mut() = None; - clear_info(viewer); - - // The thumbnail the gallery already decoded stands in for the full photo - // while it downloads: blurry for a moment beats black for a second. - match ui.photo_tex.borrow().get(&uid) { - Some(texture) => viewer.picture.set_paintable(Some(texture)), - None => viewer.picture.set_paintable(gtk4::gdk::Paintable::NONE), - } - - let rx = spawn_request( - ui.dirs.control_socket(), - Request::OpenPhoto { uid: uid.clone() }, - ); - let viewer = viewer.clone(); - glib::spawn_future_local(async move { - let result = rx.recv().await; - // The user may have moved on while this was in flight; that photo's own - // request owns the viewer now. - if *viewer.uid.borrow() != uid { - return; - } - viewer.spinner.stop(); - viewer.spinner.set_visible(false); - viewer.loading.set(false); - - let fail = |message: &str| { - viewer.status.set_label(message); - viewer.status.set_visible(true); - }; - match result { - Ok(Ok(Response::FilePath { path })) => match gtk4::gdk::Texture::from_filename(&path) { - Ok(texture) => { - viewer.picture.set_paintable(Some(&texture)); - *viewer.path.borrow_mut() = Some(path.clone()); - show_info(&viewer, &path, read_exif(&path)); - } - Err(e) => { - tracing::error!("Failed to load texture for {path}: {e}"); - fail("Couldn't render this photo."); - } - }, - Ok(Ok(Response::Error { message })) => fail(&message), - Ok(Ok(_)) => fail("Unexpected reply from the mount service."), - Ok(Err(_)) | Err(_) => fail("Couldn't reach Proton Drive."), - } - }); -} - -/// Warm the cache with the photo `delta` steps away, so stepping there is -/// instant. Fire-and-forget: the daemon serves control connections concurrently, -/// so this rides alongside whatever the user does next, and a failure here is -/// simply a photo that downloads on arrival like it used to. -fn prefetch_photo(ui: &Rc, viewer: &Rc, delta: i32) { - let model = &ui.gallery_model; - let current = find_photo_index(model, &viewer.uid.borrow()).unwrap_or(0) as i32; - let index = current + delta; - if index < 0 || index >= model.n_items() as i32 { - return; - } - let Some(photo) = model - .item(index as u32) - .and_downcast::() - .map(|boxed| boxed.borrow::().clone()) - else { - return; - }; - // Never prefetch a video: the lightbox skips over it, and warming it would - // mean downloading a whole clip the user won't watch from here. - if photo.kind == PhotoKind::Video { - return; - } - let rx = spawn_request( - ui.dirs.control_socket(), - Request::OpenPhoto { uid: photo.uid }, - ); - glib::spawn_future_local(async move { - let _ = rx.recv().await; - }); -} - -/// Reset the details drawer while the next photo is in flight, so it never shows -/// the previous photo's camera against the new image. -fn clear_info(viewer: &Rc) { - *viewer.coords.borrow_mut() = None; - viewer.info_map.set_visible(false); - while let Some(row) = viewer.info_rows.first_child() { - viewer.info_rows.remove(&row); - } - let label = gtk4::Label::builder() - .label("Reading photo details…") - .halign(gtk4::Align::Start) - .build(); - label.add_css_class("dim-label"); - viewer.info_rows.append(&label); -} - -/// Fill the details drawer for the photo now on screen: its own file facts -/// (size, dimensions) plus whatever EXIF it carries. -fn show_info(viewer: &Rc, path: &str, info: ExifInfo) { - while let Some(row) = viewer.info_rows.first_child() { - viewer.info_rows.remove(&row); - } - - let mut fields: Vec<(&str, String)> = Vec::new(); - if let Ok(meta) = std::fs::metadata(path) { - fields.push(("Size", human_bytes(meta.len()))); - } - fields.extend(info.fields.iter().map(|(k, v)| (*k, v.clone()))); - - if fields.is_empty() { - let label = gtk4::Label::builder() - .label("This photo carries no metadata.") - .halign(gtk4::Align::Start) - .wrap(true) - .build(); - label.add_css_class("dim-label"); - viewer.info_rows.append(&label); - } else { - let group = gtk4::ListBox::new(); - group.set_selection_mode(gtk4::SelectionMode::None); - group.add_css_class("boxed-list"); - for (label, value) in fields { - let row = adw::ActionRow::builder() - .title(label) - .subtitle(value) - .subtitle_selectable(true) - .build(); - row.add_css_class("property"); - group.append(&row); - } - viewer.info_rows.append(&group); - } - - viewer.info_map.set_visible(info.coords.is_some()); - *viewer.coords.borrow_mut() = info.coords; -} - -/// Read the EXIF tags a gallery viewer cares about out of a decrypted photo on -/// disk. Anything missing is simply left out — phone screenshots and re-encoded -/// images legitimately carry no EXIF at all. -fn read_exif(path: &str) -> ExifInfo { - let mut fields: Vec<(&'static str, String)> = Vec::new(); - let mut coords = None; - - let reader = match std::fs::File::open(path) { - Ok(file) => { - match exif::Reader::new().read_from_container(&mut std::io::BufReader::new(file)) { - Ok(reader) => reader, - Err(e) => { - tracing::debug!("no exif in {path}: {e}"); - return ExifInfo { fields, coords }; - } - } - } - Err(e) => { - tracing::warn!("cannot open {path} for exif: {e}"); - return ExifInfo { fields, coords }; - } - }; - - let field = |tag: exif::Tag| { - reader - .get_field(tag, exif::In::PRIMARY) - .map(|f| f.display_value().with_unit(&reader).to_string()) - }; - - if let Some(size) = field(exif::Tag::PixelXDimension) - .zip(field(exif::Tag::PixelYDimension)) - .map(|(w, h)| format!("{w} × {h}")) - { - fields.push(("Dimensions", size)); - } - if let Some(taken) = field(exif::Tag::DateTimeOriginal) { - fields.push(("Taken", taken)); - } - - let camera = [exif::Tag::Make, exif::Tag::Model] - .iter() - .filter_map(|tag| field(*tag)) - .collect::>() - .join(" "); - if !camera.is_empty() { - fields.push(("Camera", camera)); - } - if let Some(lens) = field(exif::Tag::LensModel) { - fields.push(("Lens", lens)); - } - - let exposure: Vec = [ - exif::Tag::FNumber, - exif::Tag::ExposureTime, - exif::Tag::PhotographicSensitivity, - exif::Tag::FocalLength, - ] - .iter() - .filter_map(|tag| field(*tag)) - .collect(); - if !exposure.is_empty() { - fields.push(("Exposure", exposure.join(" · "))); - } - - if let Some(lat) = gps_degrees(&reader, exif::Tag::GPSLatitude, exif::Tag::GPSLatitudeRef) - && let Some(lon) = gps_degrees(&reader, exif::Tag::GPSLongitude, exif::Tag::GPSLongitudeRef) - { - fields.push(("Location", format!("{lat:.5}, {lon:.5}"))); - coords = Some((lat, lon)); - } - - ExifInfo { fields, coords } -} - -/// Convert one GPS coordinate from EXIF's degrees/minutes/seconds rationals to -/// decimal degrees, negating for the S/W hemispheres. -fn gps_degrees(reader: &exif::Exif, tag: exif::Tag, ref_tag: exif::Tag) -> Option { - let field = reader.get_field(tag, exif::In::PRIMARY)?; - let exif::Value::Rational(dms) = &field.value else { - return None; - }; - let [deg, min, sec] = dms.get(..3)? else { - return None; - }; - let degrees = deg.to_f64() + min.to_f64() / 60.0 + sec.to_f64() / 3600.0; - - let hemisphere = reader - .get_field(ref_tag, exif::In::PRIMARY) - .map(|f| f.display_value().to_string()) - .unwrap_or_default(); - let negative = hemisphere.starts_with('S') || hemisphere.starts_with('W'); - Some(if negative { -degrees } else { degrees }) -} - -fn save_photo_to_disk(window: >k4::Window, source_path: &str, original_name: &str) { - let dialog = gtk4::FileDialog::builder() - .title("Save Photo") - .initial_name(original_name) - .build(); - 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); - } - } - }); -} - -/// Step `delta` photos through the flat timeline model and load what lands there. -fn navigate_photo(ui: &Rc, viewer: &Rc, delta: i32) { - let model = &ui.gallery_model; - let n = model.n_items(); - if n == 0 { - return; - } - let uid_val = viewer.uid.borrow().clone(); - let current_idx = find_photo_index(model, &uid_val).unwrap_or(0); - - // Where the raw step lands (prev/next are ±1; Home/End are huge deltas that - // clamp to the ends), and which way it was travelling. - let start = (current_idx as i32 + delta).clamp(0, n as i32 - 1); - let dir = if delta < 0 { -1 } else { 1 }; - - // The lightbox renders stills only — a video plays in an external player, so - // it has no frame here. Land on the nearest renderable item, preferring the - // travel direction and falling back to scanning inward from a boundary (so - // Home/End still reach the first/last still even when videos sit at the end). - let renderable = |idx: u32| -> Option { - model - .item(idx) - .and_then(|obj| obj.downcast_ref::().map(|b| b.borrow::().clone())) - .filter(|photo| photo.kind != PhotoKind::Video) - }; - let scan = |from: i32, step: i32| -> Option<(u32, PhotoItem)> { - let mut probe = from; - while (0..n as i32).contains(&probe) { - if let Some(photo) = renderable(probe as u32) { - return Some((probe as u32, photo)); - } - probe += step; - } - None - }; - // Scan the travel direction first. Only fall back to scanning inward when the - // step landed on a boundary — that is the Home/End case (jump to first/last, - // then step inward past any videos there); a mid-list prev/next with only - // videos ahead should simply stay put rather than reverse. - let at_boundary = start == 0 || start == n as i32 - 1; - let landing = scan(start, dir).or_else(|| at_boundary.then(|| scan(start, -dir)).flatten()); - let Some((next_idx, photo)) = landing else { - return; - }; - - *viewer.uid.borrow_mut() = photo.uid.clone(); - show_photo_position(viewer, next_idx, n, photo.capture_time); - load_photo(ui, viewer, photo.uid.clone()); - // Keep walking in the same direction: the next one is likely where they're - // headed, so have it in the cache before they ask. - prefetch_photo(ui, viewer, delta.signum()); -} - -/// Set the lightbox's title (the photo's date) and its "12 of 340" counter. -fn show_photo_position(viewer: &Rc, index: u32, total: u32, capture_time: i64) { - viewer.prev.set_sensitive(index > 0); - viewer.next.set_sensitive(index + 1 < total); - viewer.title.set_label(&format_capture_time(capture_time)); - viewer - .counter - .set_label(&format!("{} of {total}", index + 1)); -} - -/// The in-app lightbox: the photo, edge-to-edge on a dark backdrop, with a -/// floating top bar, prev/next affordances and a details drawer. -/// -/// Closing it is deliberately hard to get wrong — Escape, `q`, Ctrl+W, the close -/// button, or a click on the backdrop beside the photo all dismiss it. -fn open_photo_viewer(ui: &Rc, initial_uid: String) { - let parent = ui.stack.root().and_downcast::().unwrap(); - - let window = gtk4::Window::builder() - .title("Photo") - .modal(true) - .transient_for(&parent) - .default_width(1100) - .default_height(760) - .build(); - window.add_css_class("photo-viewer-window"); - - let overlay = gtk4::Overlay::new(); - - let picture = gtk4::Picture::builder() - .content_fit(gtk4::ContentFit::Contain) - .hexpand(true) - .vexpand(true) - .build(); - overlay.set_child(Some(&picture)); - - let prev_btn = gtk4::Button::builder() - .icon_name("go-previous-symbolic") - .tooltip_text("Previous (←)") - .halign(gtk4::Align::Start) - .valign(gtk4::Align::Center) - .build(); - prev_btn.add_css_class("circular"); - prev_btn.add_css_class("flat"); - prev_btn.add_css_class("viewer-nav-btn"); - overlay.add_overlay(&prev_btn); - - let next_btn = gtk4::Button::builder() - .icon_name("go-next-symbolic") - .tooltip_text("Next (→)") - .halign(gtk4::Align::End) - .valign(gtk4::Align::Center) - .build(); - next_btn.add_css_class("circular"); - next_btn.add_css_class("flat"); - next_btn.add_css_class("viewer-nav-btn"); - overlay.add_overlay(&next_btn); - - // Top bar: the photo's date and position on the left, actions on the right, - // over a gradient so white controls stay legible on a bright photo. - let title_label = gtk4::Label::builder() - .halign(gtk4::Align::Start) - .ellipsize(gtk4::pango::EllipsizeMode::End) - .build(); - title_label.add_css_class("viewer-title"); - - let counter_label = gtk4::Label::builder().halign(gtk4::Align::Start).build(); - counter_label.add_css_class("viewer-counter"); - - let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 1); - titles.set_hexpand(true); - titles.set_valign(gtk4::Align::Center); - titles.append(&title_label); - titles.append(&counter_label); - - let action = |icon: &str, tooltip: &str| { - let button = gtk4::Button::builder() - .icon_name(icon) - .tooltip_text(tooltip) - .valign(gtk4::Align::Center) - .build(); - button.add_css_class("flat"); - button.add_css_class("viewer-action-btn"); - button - }; - - let info_toggle = gtk4::ToggleButton::builder() - .icon_name("info-outline-symbolic") - .tooltip_text("Details (i)") - .valign(gtk4::Align::Center) - .build(); - info_toggle.add_css_class("flat"); - info_toggle.add_css_class("viewer-action-btn"); - - let download_btn = action("document-save-symbolic", "Save a copy…"); - let open_ext_btn = action("document-open-symbolic", "Open with another app"); - let close_btn = action("window-close-symbolic", "Close (Esc)"); - close_btn.add_css_class("viewer-close-btn"); - - let top_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); - top_bar.add_css_class("viewer-top-bar"); - top_bar.set_valign(gtk4::Align::Start); - top_bar.set_hexpand(true); - top_bar.append(&titles); - top_bar.append(&info_toggle); - top_bar.append(&download_btn); - top_bar.append(&open_ext_btn); - top_bar.append(&close_btn); - overlay.add_overlay(&top_bar); - - // Details drawer: slides in from the right as a real surface (not a wash of - // black over the photo), with its own header and its own way out. - let info_rows = gtk4::Box::new(gtk4::Orientation::Vertical, 12); - - let info_map = gtk4::Button::builder() - .label("Show on map") - .icon_name("map-symbolic") - .halign(gtk4::Align::Start) - .build(); - info_map.add_css_class("pill"); - info_map.set_visible(false); - - let info_title = gtk4::Label::builder() - .label("Details") - .halign(gtk4::Align::Start) - .hexpand(true) - .build(); - info_title.add_css_class("heading"); - - let info_close = gtk4::Button::builder() - .icon_name("window-close-symbolic") - .tooltip_text("Hide details") - .build(); - info_close.add_css_class("flat"); - info_close.add_css_class("circular"); - - let info_header = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); - info_header.append(&info_title); - info_header.append(&info_close); - - let info_body = gtk4::Box::new(gtk4::Orientation::Vertical, 12); - info_body.set_margin_top(16); - info_body.set_margin_bottom(16); - info_body.set_margin_start(16); - info_body.set_margin_end(16); - info_body.append(&info_header); - info_body.append(&info_rows); - info_body.append(&info_map); - - // Scrolled, because a photo with a full EXIF block plus a location can - // outgrow a short window. - let info_scroll = gtk4::ScrolledWindow::builder() - .hscrollbar_policy(gtk4::PolicyType::Never) - .propagate_natural_height(true) - .child(&info_body) - .build(); - - let info_panel = gtk4::Box::new(gtk4::Orientation::Vertical, 0); - info_panel.add_css_class("viewer-info-panel"); - info_panel.set_width_request(320); - info_panel.append(&info_scroll); - - let info_revealer = gtk4::Revealer::builder() - .transition_type(gtk4::RevealerTransitionType::SlideLeft) - .halign(gtk4::Align::End) - .valign(gtk4::Align::Fill) - .child(&info_panel) - .build(); - overlay.add_overlay(&info_revealer); - - let spinner = gtk4::Spinner::builder() - .halign(gtk4::Align::Center) - .valign(gtk4::Align::Center) - .width_request(48) - .height_request(48) - .build(); - spinner.add_css_class("viewer-spinner"); - overlay.add_overlay(&spinner); - - let status_label = gtk4::Label::builder() - .wrap(true) - .justify(gtk4::Justification::Center) - .halign(gtk4::Align::Center) - .valign(gtk4::Align::Center) - .build(); - status_label.add_css_class("viewer-status"); - overlay.add_overlay(&status_label); - - window.set_child(Some(&overlay)); - - let viewer = Rc::new(Viewer { - picture: picture.clone(), - spinner, - status: status_label, - title: title_label, - counter: counter_label, - prev: prev_btn.clone(), - next: next_btn.clone(), - info_toggle: info_toggle.clone(), - info_revealer: info_revealer.clone(), - info_rows, - info_map: info_map.clone(), - coords: RefCell::new(None), - uid: RefCell::new(initial_uid.clone()), - path: RefCell::new(None), - loading: Cell::new(false), - }); - - let n = ui.gallery_model.n_items(); - let initial_idx = find_photo_index(&ui.gallery_model, &initial_uid).unwrap_or(0); - let capture_time = ui - .gallery_model - .item(initial_idx) - .and_downcast::() - .map_or(0, |boxed| boxed.borrow::().capture_time); - show_photo_position(&viewer, initial_idx, n, capture_time); - - load_photo(ui, &viewer, initial_uid); - prefetch_photo(ui, &viewer, 1); - - let w_close = window.clone(); - close_btn.connect_clicked(move |_| { - w_close.close(); - }); - - let viewer_info = viewer.clone(); - info_toggle.connect_toggled(move |toggle| { - viewer_info - .info_revealer - .set_reveal_child(toggle.is_active()); - }); - - let toggle_off = info_toggle.clone(); - info_close.connect_clicked(move |_| toggle_off.set_active(false)); - - let viewer_map = viewer.clone(); - info_map.connect_clicked(move |_| { - if let Some((lat, lon)) = *viewer_map.coords.borrow() { - open_path(&format!( - "https://www.openstreetmap.org/?mlat={lat:.6}&mlon={lon:.6}#map=16/{lat:.6}/{lon:.6}" - )); - } - }); - - let w_download = window.clone(); - let viewer_download = viewer.clone(); - download_btn.connect_clicked(move |_| { - if let Some(path) = viewer_download.path.borrow().as_deref() { - let name = format!("{}.jpg", viewer_download.uid.borrow()); - save_photo_to_disk(&w_download, path, &name); - } - }); - - let viewer_ext = viewer.clone(); - open_ext_btn.connect_clicked(move |_| { - if let Some(path) = viewer_ext.path.borrow().as_deref() { - open_path(path); - } - }); - - let ui_prev = ui.clone(); - let viewer_prev = viewer.clone(); - prev_btn.connect_clicked(move |_| { - navigate_photo(&ui_prev, &viewer_prev, -1); - }); - - let ui_next = ui.clone(); - let viewer_next = viewer.clone(); - next_btn.connect_clicked(move |_| { - navigate_photo(&ui_next, &viewer_next, 1); - }); - - // Click the backdrop — the dark area beside the photo — to dismiss, the way - // every other lightbox behaves. Clicks on the photo itself are left alone, so - // reaching for the image doesn't fling the window shut. - let backdrop = gtk4::GestureClick::new(); - let viewer_click = viewer.clone(); - let w_click = window.clone(); - backdrop.connect_released(move |_, _, x, y| { - if !over_photo(&viewer_click.picture, x, y) { - w_click.close(); - } - }); - picture.add_controller(backdrop); - - let key_controller = gtk4::EventControllerKey::new(); - let ui_key = ui.clone(); - let viewer_key = viewer.clone(); - let w_key = window.clone(); - key_controller.connect_key_pressed(move |_, key, _keycode, state| { - let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK); - match key.name().as_deref() { - Some("Left" | "Up" | "BackSpace") => navigate_photo(&ui_key, &viewer_key, -1), - Some("Right" | "Down" | "space") => navigate_photo(&ui_key, &viewer_key, 1), - Some("Home") => navigate_photo(&ui_key, &viewer_key, i32::MIN / 2), - Some("End") => navigate_photo(&ui_key, &viewer_key, i32::MAX / 2), - Some("i") => viewer_key - .info_toggle - .set_active(!viewer_key.info_toggle.is_active()), - Some("f" | "F11") => { - if w_key.is_fullscreen() { - w_key.unfullscreen(); - } else { - w_key.fullscreen(); - } - } - Some("Escape" | "q") => w_key.close(), - Some("w") if ctrl => w_key.close(), - _ => return glib::Propagation::Proceed, - } - glib::Propagation::Stop - }); - window.add_controller(key_controller); - - window.present(); -} - -/// Whether `(x, y)` — in `picture`'s coordinates — lands on the photo itself -/// rather than the backdrop around it. [`gtk4::ContentFit::Contain`] centres the -/// image and letterboxes the rest, so the drawn rectangle is the widget scaled -/// down by whichever axis binds. -fn over_photo(picture: >k4::Picture, x: f64, y: f64) -> bool { - let (width, height) = (f64::from(picture.width()), f64::from(picture.height())); - let Some(paintable) = picture.paintable() else { - return false; - }; - let (iw, ih) = ( - f64::from(paintable.intrinsic_width()), - f64::from(paintable.intrinsic_height()), - ); - if iw <= 0.0 || ih <= 0.0 { - return false; - } - - let scale = (width / iw).min(height / ih); - let (drawn_w, drawn_h) = (iw * scale, ih * scale); - let (left, top) = ((width - drawn_w) / 2.0, (height - drawn_h) / 2.0); - x >= left && x <= left + drawn_w && y >= top && y <= top + drawn_h -} - -/// Fetch a timeline page from the daemon. When `append` is false the model is -/// cleared first (fresh load); otherwise the next page is tacked on. -fn load_gallery(ui: &Rc, append: bool) { - if ui.gallery_loading.get() { - return; - } - if !append { - // Fresh load: clear the timeline and show Loading until the first page lands. - ui.gallery_model.remove_all(); - gallery_status( - ui, - "image-x-generic-symbolic", - "Loading photos…", - "Reading your Proton Drive timeline.", - false, - ); - // Rebuild the date jump for the current kind, but only for a full-span - // load — a jump *to* a month sets a range and reloads, and refreshing the - // dropdown then would fight the selection the user just made. - if ui.gallery_range.get().is_none() { - refresh_photo_months(ui); - } - } - let offset = ui.gallery_model.n_items() as usize; - ui.gallery_loading.set(true); - ui.gallery_more.set_sensitive(false); - - ui.busy_begin(); - let rx = spawn_request( - ui.dirs.control_socket(), - Request::PhotosTimeline { - offset, - limit: PHOTOS_PAGE, - kind: ui.gallery_kind.get(), - range: ui.gallery_range.get(), - }, - ); - let ui = ui.clone(); - glib::spawn_future_local(async move { - let result = rx.recv().await; - ui.busy_end(); - ui.gallery_loading.set(false); - ui.gallery_more.set_sensitive(true); - match result { - Ok(Ok(Response::Photos { - available, - items, - counts, - })) => { - if !available { - gallery_status( - &ui, - "image-missing-symbolic", - "No photo library", - "This Proton account doesn't have Photos enabled.", - false, - ); - return; - } - // Label the filter tabs with live per-kind counts. - if let Some(counts) = counts { - update_gallery_tabs(&ui, counts); - } - // Take the daemon's word on ratios and thumbnail verdicts before - // anything is laid out: it persists both, so the very first frame - // justifies its rows correctly instead of guessing RATIO_UNKNOWN - // and reflowing as the images land. - { - let mut ratios = ui.photo_ratio.borrow_mut(); - let mut nothumb = ui.photo_nothumb.borrow_mut(); - for item in &items { - if let Some(ratio) = item.ratio { - ratios.insert(item.uid.clone(), ratio); - } - if item.no_thumb { - nothumb.insert(item.uid.clone()); - } - } - } - for item in &items { - ui.gallery_model.append(&BoxedAnyObject::new(item.clone())); - } - repaint_gallery(&ui); - if ui.gallery_model.n_items() == 0 { - gallery_status( - &ui, - "image-x-generic-symbolic", - "No photos yet", - "Photos you upload to Proton Drive appear here.", - false, - ); - return; - } - ui.gallery_content.set_visible_child_name("timeline"); - // Offer "Load more" only when the page came back full. - ui.gallery_more.set_visible(items.len() == PHOTOS_PAGE); - } - // A failed *next* page keeps the photos already on screen — the failure - // goes to a toast rather than wiping the timeline for a status page. - Ok(Ok(Response::Error { message })) if append => { - toast_error(&ui, "Couldn't load more photos", &message) - } - Ok(Ok(Response::Error { message })) => gallery_status( - &ui, - "dialog-warning-symbolic", - "Couldn't load photos", - &message, - false, - ), - Ok(Ok(_)) => gallery_status( - &ui, - "dialog-warning-symbolic", - "Couldn't load photos", - "Unexpected reply from the mount service.", - false, - ), - Ok(Err(_)) | Err(_) if append => toast_error( - &ui, - "Couldn't load more photos", - "The mount service didn't respond.", - ), - Ok(Err(_)) | Err(_) => gallery_unreachable(&ui), - } - }); -} - -/// Swap the Photos content area to the status page, hiding the pager. Retry is -/// offered only when restarting the mount service could actually fix it. -fn gallery_status(ui: &Rc, icon: &str, title: &str, description: &str, retry: bool) { - ui.gallery_status.set_icon_name(Some(icon)); - ui.gallery_status.set_title(title); - ui.gallery_status.set_description(Some(description)); - ui.gallery_retry.set_visible(retry); - ui.gallery_more.set_visible(false); - ui.gallery_content.set_visible_child_name("status"); -} - -/// Photos counterpart of [`browser_unreachable`]: auto-retry while the mount is -/// still starting, surface an actionable error + Retry once it's actually down. -fn gallery_unreachable(ui: &Rc) { - if service::is_failed() || !service::is_active() { - gallery_status( - ui, - "network-offline-symbolic", - "Not connected", - "The Proton Drive mount service isn't running.", - true, - ); - return; - } - gallery_status( - ui, - "folder-remote-symbolic", - "Connecting…", - "Waiting for the Proton Drive mount service to come up.", - false, - ); - let ui = ui.clone(); - glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || { - if ui.stack.visible_child_name().as_deref() == Some("gallery") { - load_gallery(&ui, false); - } - }); -} - -/// Open a local path with the user's default handler. -fn open_path(path: &str) { - if let Err(e) = Command::new("xdg-open").arg(path).spawn() { - tracing::error!("xdg-open {path} failed: {e}"); - } -} - -/// Play a video with an external player. Prefers `mpv` — it sniffs the container -/// from the bytes, so the cache's extensionless blob plays fine, and it is the -/// right tool for the HEVC `.mkv`s this is aimed at — and falls back to the -/// user's default handler when mpv isn't installed. -fn play_external(path: &str) { - if Command::new("mpv").arg(path).spawn().is_ok() { - return; - } - open_path(path); -} - -/// Download a Photos-library video, then hand it to an external player. Unlike a -/// still photo — which the in-app lightbox can render — a video needs a real -/// player, and the photos volume isn't part of the FUSE mount, so there is no -/// path to stream it from: [`Request::OpenPhoto`] fetches the whole file into the -/// cache (served straight from there on a repeat) and we launch the player on it. -/// -/// For large videos kept in an on-demand *drive* folder, streaming through the -/// mount is the better route — that is the file-browser "Play" action, not this. -fn play_video(ui: &Rc, uid: String) { - toast(ui, "Preparing video…"); - let rx = spawn_request(ui.dirs.control_socket(), Request::OpenPhoto { uid }); - let ui = ui.clone(); - glib::spawn_future_local(async move { - match rx.recv().await { - Ok(Ok(Response::FilePath { path })) => play_external(&path), - Ok(Ok(Response::Error { message })) => { - toast_error(&ui, "Couldn't open this video", &message) - } - Ok(Ok(_)) => toast_error( - &ui, - "Couldn't open this video", - "Unexpected reply from the mount service.", - ), - Ok(Err(_)) | Err(_) => { - toast_error(&ui, "Couldn't open this video", "Couldn't reach Proton Drive.") - } - } - }); -} - -/// Format a byte count as a short binary-unit string (e.g. `1.2 GiB`). -fn human_bytes(bytes: u64) -> String { - const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"]; - if bytes == 0 { - return "0 B".into(); - } - let mut value = bytes as f64; - let mut unit = 0; - while value >= 1024.0 && unit < UNITS.len() - 1 { - value /= 1024.0; - unit += 1; - } - if unit == 0 { - format!("{bytes} B") - } else { - format!("{value:.1} {}", UNITS[unit]) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - /// A justified row must fill the content width exactly: tile widths plus the - /// gaps between them add up to the width, with nothing left ragged. - fn row_width(row: &[(i32, i32)]) -> i32 { - let gaps = TILE_GAP * (row.len().saturating_sub(1)) as i32; - row.iter().map(|(width, _)| *width).sum::() + gaps - } - - #[test] - fn justified_rows_fill_the_width_exactly() { - let ratios = [ - 1.5, 0.75, 1.5, 1.33, 0.66, 1.5, 1.0, 2.2, 1.5, 0.8, 1.5, 1.6, - ]; - let rows = plan_rows(&ratios, 180.0, 1000.0); - - assert!( - rows.len() > 1, - "12 photos at 180px should need several rows" - ); - // Every row but the trailing one is justified. - for row in &rows[..rows.len() - 1] { - assert_eq!(row_width(row), 1000); - } - } - - #[test] - fn every_photo_lands_in_exactly_one_row() { - let ratios: Vec = (0..37).map(|i| 0.5 + f64::from(i % 5) * 0.4).collect(); - let rows = plan_rows(&ratios, 180.0, 1000.0); - let tiles: usize = rows.iter().map(Vec::len).sum(); - assert_eq!(tiles, ratios.len()); - } - - #[test] - fn tiles_keep_their_aspect_ratio() { - let rows = plan_rows(&[1.5, 1.5, 1.5, 1.5, 1.5, 1.5, 1.5, 1.5], 180.0, 1000.0); - // All but the last tile of a justified row are sized straight from the - // ratio, so w/h is the photo's own — no cropping needed to place it. - for row in &rows { - for (width, height) in &row[..row.len() - 1] { - let ratio = f64::from(*width) / f64::from(*height); - assert!((ratio - 1.5).abs() < 0.02, "{width}x{height} is not 3:2"); - } - } - } - - #[test] - fn trailing_row_is_not_stretched() { - // Two photos can't fill a 1000px row at 180px tall, so they must keep the - // target height rather than being blown up to a screen-wide row. - let rows = plan_rows(&[1.5, 1.5], 180.0, 1000.0); - assert_eq!(rows.len(), 1); - assert!(rows[0].iter().all(|(_, height)| *height == 180)); - assert!(row_width(&rows[0]) < 1000); - } - - #[test] - fn an_extreme_panorama_cannot_squash_its_row() { - // Unclamped, a 20:1 panorama would drag its whole row down to ~40px tall. - // Clamped at RATIO_MAX it stays a wide tile in a row of normal height. - let rows = plan_rows(&[20.0, 1.5, 1.5], 180.0, 1000.0); - let (width, height) = rows[0][0]; - assert!(height > 120, "row squashed to {height}px"); - assert!(width <= 1000, "tile overflows the row at {width}px"); - assert_eq!(row_width(&rows[0]), 1000); - } - - #[test] - fn narrow_widths_and_no_photos_are_survivable() { - assert!(plan_rows(&[], 180.0, 1000.0).is_empty()); - // Width below the floor is clamped rather than producing zero-px tiles. - let rows = plan_rows(&[1.5, 1.5], 180.0, 0.0); - assert!( - rows.iter() - .flatten() - .all(|(width, height)| *width > 0 && *height > 0) - ); - } -} diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs new file mode 100644 index 0000000..311e309 --- /dev/null +++ b/crates/pdfs-gui/src/app/main.rs @@ -0,0 +1,1107 @@ +pub(crate) mod pages; +pub(crate) mod widgets; + +use pages::activity::*; +use pages::browser::*; +use pages::devices::*; +use pages::login::*; +use pages::photo_viewer::*; +use pages::photos::*; +use pages::shared::*; +use pages::shared_by_me::*; +use pages::status::*; +use pages::trash::*; +use widgets::details::*; +use widgets::share_dialog::*; + +use std::cell::{Cell, RefCell}; + +use std::collections::{HashMap, HashSet, VecDeque}; + +use std::path::{Path, PathBuf}; + +use std::process::Command; + +use std::rc::Rc; + +use std::time::{Duration, Instant}; + +use adw::prelude::*; + +use gtk4::gio; + +use gtk4::glib; + +use gtk4::glib::BoxedAnyObject; + +use pdfs_core::auth; + +use pdfs_core::config::AppDirs; + +use pdfs_core::control::{ + ErrorKind, + ActivityEntry, ActivityKind, BookmarkInfo, DeviceInfo, DirEntry, InvitationInfo, JobItem, + PhotoItem, PhotoKind, PublicLinkInfo, RefreshScope, Request, Response, SearchHit, ShareEntry, + ShareEntryKind, SharedItem, SyncFolderInfo, SyncPhase, SyncProgress, TransferDirection, + TransferItem, pending_summary, send, +}; + +use pdfs_core::service; + +const APP_ID: &str = "io.narl.proton-drive-linux"; + +/// Proton brand purple, applied as the libadwaita accent so switches, buttons, +/// links and the storage bar all pick it up. +const PROTON_PURPLE: &str = "#6d4aff"; + +/// How often the window re-reads mount status, cache usage and the pin list. +const REFRESH_INTERVAL: Duration = Duration::from_secs(2); + +/// Backoff between auto-retries of a Files/Photos load while the mount service +/// is still coming up (see [`load_browser`] / [`load_gallery`]). +const CONNECT_RETRY_INTERVAL: Duration = Duration::from_secs(2); + +/// How long a network-backed page (Shared, Shared-by-me, Devices, Activity) is +/// considered fresh. Re-navigating to it within this window reuses the rows +/// already on screen instead of re-fetching and flashing the "Loading…" +/// placeholder. The Retry button and every mutation still force an immediate +/// reload by clearing the page's timestamp. +const PAGE_TTL: Duration = Duration::from_secs(30); + +/// All widgets the periodic refresh and the action handlers mutate, plus the +/// resolved paths they act on. Wrapped in an [`Rc`] so handlers and the timeout +/// closure share one instance. +struct Ui { + dirs: AppDirs, + stack: adw::ViewStack, + /// Wraps the whole window content; every non-blocking outcome (a completed + /// rename, a failed upload, a purge) is reported here rather than in a modal, + /// so an action never interrupts what the user is doing next. + toasts: adw::ToastOverlay, + /// Header spinner shown while any open/load round-trip is in flight; ref- + /// counted via [`Self::busy`] so concurrent operations don't stop it early. + spinner: gtk4::Spinner, + busy: Cell, + /// Keys (relative path / photo uid) of open requests currently in flight, so + /// a double-click on the same entry is a no-op instead of a second download. + opening: RefCell>, + /// Resolved login identity, cached so the periodic [`refresh`] never hits the + /// keyring (a DBus round-trip). Populated at startup and updated only on + /// login / logout. `None` = signed out. + session: RefCell>, + /// Whether the last refresh saw a live mount daemon. Gates the unpin buttons + /// (which need the daemon to evict + re-hydrate) and every mutating action. + mounted: RefCell, + /// Sidebar destination list (Files / Photos / Settings). Selecting a row swaps + /// the page stack; [`sync_sidebar`] mirrors navigation that starts elsewhere. + sidebar: gtk4::ListBox, + /// The sidebar/content split. Collapsed while signed out, so the login page + /// owns the whole window and no destination is reachable without a session. + nav: adw::NavigationSplitView, + + // Per-page state. Each page module owns its own struct; `Ui` keeps only + // what more than one page genuinely shares. + pub(crate) login: LoginState, + pub(crate) status: StatusState, + pub(crate) browser: BrowserState, + pub(crate) details: DetailsState, + pub(crate) trash: TrashState, + pub(crate) gallery: GalleryState, + pub(crate) shared: SharedState, + pub(crate) shared_by_me: SharedByMeState, + pub(crate) devices: DevicesState, + pub(crate) activity: ActivityState, +} + +impl Ui { + /// Begin a unit of background work: show + spin the header spinner. + fn busy_begin(&self) { + self.busy.set(self.busy.get() + 1); + self.spinner.set_visible(true); + self.spinner.start(); + } + + /// End a unit of background work: stop the spinner once the last one is done. + fn busy_end(&self) { + let remaining = self.busy.get().saturating_sub(1); + self.busy.set(remaining); + if remaining == 0 { + self.spinner.stop(); + self.spinner.set_visible(false); + } + } + + /// The aspect ratio (w/h) to lay `uid`'s tile out at: the real one once its + /// thumbnail has been seen, otherwise [`RATIO_UNKNOWN`]. + fn ratio(&self, uid: &str) -> f64 { + self.gallery.photo_ratio + .borrow() + .get(uid) + .copied() + .unwrap_or(RATIO_UNKNOWN) + } + + /// Remember a decoded thumbnail and the aspect ratio it revealed. Returns + /// true when the ratio is *new information* — the caller re-justifies the + /// affected rows, since they were laid out against a guess. + fn store_texture(&self, uid: &str, texture: gtk4::gdk::Texture) -> bool { + let (w, h) = (texture.width(), texture.height()); + + let mut cache = self.gallery.photo_tex.borrow_mut(); + let mut order = self.gallery.photo_tex_order.borrow_mut(); + if cache.insert(uid.to_string(), texture).is_none() { + order.push_back(uid.to_string()); + } + while order.len() > TEXTURE_CACHE_MAX { + if let Some(old) = order.pop_front() { + cache.remove(&old); + } + } + drop((cache, order)); + + if h <= 0 { + return false; + } + let ratio = f64::from(w) / f64::from(h); + let mut ratios = self.gallery.photo_ratio.borrow_mut(); + match ratios.insert(uid.to_string(), ratio) { + // Only a *changed* ratio invalidates a layout; re-decoding a photo we + // already sized correctly must not trigger another pass. + Some(prev) if (prev - ratio).abs() < f64::EPSILON => false, + _ => { + self.gallery.photo_ratio_dirty.set(true); + true + } + } + } + + /// Where the learned aspect ratios are persisted between runs. + fn ratio_path(&self) -> PathBuf { + self.dirs.cache_dir().join("photo-ratios.json") + } + + /// Load the persisted aspect ratios. A missing or corrupt file just means the + /// first paint uses [`RATIO_UNKNOWN`] and re-justifies as thumbnails land. + fn load_ratios(&self) { + let path = self.ratio_path(); + let Ok(text) = std::fs::read_to_string(&path) else { + return; + }; + match serde_json::from_str::>(&text) { + Ok(map) => *self.gallery.photo_ratio.borrow_mut() = map, + Err(e) => tracing::debug!("ignoring {}: {e}", path.display()), + } + } + + /// Persist the learned aspect ratios, if any were learned since the last save. + fn save_ratios(&self) { + if !self.gallery.photo_ratio_dirty.replace(false) { + return; + } + let path = self.ratio_path(); + let ratios = self.gallery.photo_ratio.borrow(); + let write = serde_json::to_vec(&*ratios) + .map_err(std::io::Error::other) + .and_then(|bytes| { + std::fs::create_dir_all(path.parent().unwrap_or(Path::new("/")))?; + std::fs::write(&path, bytes) + }); + if let Err(e) = write { + tracing::debug!("cannot save {}: {e}", path.display()); + } + } +} + +fn main() -> glib::ExitCode { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), + ) + .init(); + + let app = adw::Application::builder().application_id(APP_ID).build(); + app.connect_startup(|_| { + load_proton_theme(); + // Refresh the file manager's right-click pin/unpin scripts, so they always + // match the installed `pdfs`. + pdfs_core::shell::install_file_manager_scripts(); + spawn_tray(); + }); + app.connect_activate(build_window); + app.run() +} + +/// Spawn the tray icon process in the background. +fn spawn_tray() { + match Command::new("pdfs-tray").spawn() { + Ok(_) => tracing::info!("spawned `pdfs-tray`"), + Err(e) => tracing::error!("failed to spawn `pdfs-tray`: {e}"), + } +} + +/// Install a CSS provider that overrides libadwaita's accent colour with Proton +/// purple, app-wide. Named-colour overrides recolour the stock widgets (switch, +/// buttons, progress fill) without per-widget styling. +fn load_proton_theme() { + // Compile-in and register our custom GResources (e.g. custom icons) + let bytes = include_bytes!(concat!(env!("OUT_DIR"), "/pdfs.gresource")); + let resource_data = glib::Bytes::from_static(bytes); + if let Ok(resource) = gio::Resource::from_data(&resource_data) { + gio::resources_register(&resource); + } else { + tracing::error!("failed to load gresource bundle"); + } + + let css = format!( + "@define-color accent_bg_color {PROTON_PURPLE};\n\ + @define-color accent_color {PROTON_PURPLE};\n\ + .brand-title {{ font-size: 1.6rem; font-weight: 800; }}\n\ + .brand-icon {{ color: {PROTON_PURPLE}; }}\n\ + .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: #111014; }}\n\ + .viewer-top-bar {{ background: linear-gradient(to bottom, rgba(0, 0, 0, 0.75), rgba(0, 0, 0, 0)); padding: 10px 16px 28px 20px; color: white; }}\n\ + .viewer-title {{ font-weight: 700; font-size: 1.05rem; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); color: white; }}\n\ + .viewer-counter {{ font-size: 0.8rem; color: rgba(255, 255, 255, 0.72); text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); }}\n\ + .viewer-action-btn {{ color: white; background-color: rgba(255, 255, 255, 0.12); border-radius: 50%; min-width: 34px; min-height: 34px; margin-left: 4px; }}\n\ + .viewer-action-btn:hover {{ background-color: rgba(255, 255, 255, 0.28); color: white; }}\n\ + .viewer-action-btn:checked {{ background-color: {PROTON_PURPLE}; color: white; }}\n\ + .viewer-close-btn {{ background-color: rgba(255, 255, 255, 0.2); margin-left: 10px; }}\n\ + .viewer-close-btn:hover {{ background-color: #e01b24; color: white; }}\n\ + .viewer-nav-btn {{ background-color: rgba(0, 0, 0, 0.45); color: white; margin: 20px; min-width: 44px; min-height: 44px; border-radius: 50%; opacity: 0.7; transition: opacity 150ms ease, background-color 150ms ease; }}\n\ + .viewer-nav-btn:hover {{ background-color: rgba(0, 0, 0, 0.85); color: white; opacity: 1; }}\n\ + .viewer-nav-btn:disabled {{ opacity: 0; }}\n\ + .viewer-spinner {{ color: white; }}\n\ + .viewer-status {{ color: white; font-size: 1rem; background-color: rgba(0, 0, 0, 0.75); padding: 12px 24px; border-radius: 12px; }}\n\ + .viewer-info-panel {{ background-color: @window_bg_color; border-left: 1px solid alpha(currentColor, 0.12); }}\n\ + .gallery-day {{ font-size: 1.05rem; font-weight: 700; padding: 2px 2px 6px 2px; }}\n\ + .photo-tile {{ padding: 0; margin: 0; min-height: 0; min-width: 0; border-radius: 10px; background: alpha(currentColor, 0.06); box-shadow: none; transition: filter 160ms ease, box-shadow 160ms ease; }}\n\ + .photo-tile:hover {{ filter: brightness(1.06); box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35); }}\n\ + .photo-tile:focus {{ outline: 2px solid {PROTON_PURPLE}; outline-offset: -2px; }}\n\ + .photo-placeholder {{ color: alpha(currentColor, 0.35); background: alpha(currentColor, 0.07); }}\n\ + .photo-caption {{ font-size: 0.78rem; color: white; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); padding: 22px 10px 6px 10px; opacity: 0; transition: opacity 160ms ease; }}\n\ + .photo-video-badge {{ color: white; background: rgba(0, 0, 0, 0.45); border-radius: 999px; padding: 8px; min-width: 20px; min-height: 20px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); transition: background 160ms ease; }}\n\ + .photo-tile:hover .photo-video-badge {{ background: alpha({PROTON_PURPLE}, 0.85); }}\n\ + .photo-tile:hover .photo-caption {{ opacity: 1; background: linear-gradient(to top, rgba(0, 0, 0, 0.55), rgba(0, 0, 0, 0)); }}\n\ + .card {{ border-radius: 8px; transition: transform 0.2s ease, filter 0.2s ease; margin: 4px; }}\n\ + .card:hover {{ transform: scale(1.02); filter: brightness(0.9); }}\n\ + .navigation-sidebar row {{ border-radius: 8px; margin: 2px 6px; }}\n\ + .navigation-sidebar row:selected {{ background: alpha({PROTON_PURPLE}, 0.16); color: {PROTON_PURPLE}; font-weight: 600; }}\n\ + .navigation-sidebar row:selected image {{ color: {PROTON_PURPLE}; }}\n\ + .file-tile:selected, .file-tile:hover:selected {{ background: alpha({PROTON_PURPLE}, 0.20); }}\n" + ); + let provider = gtk4::CssProvider::new(); + provider.load_from_string(&css); + if let Some(display) = gtk4::gdk::Display::default() { + gtk4::style_context_add_provider_for_display( + &display, + &provider, + gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, + ); + + // Register custom icons directory from our GResource with the icon theme + let icon_theme = gtk4::IconTheme::for_display(&display); + icon_theme.add_resource_path("/de/nils/protondrivelinux/icons"); + } +} + +/// Build the application window, wire the two pages, kick off the refresh loop, +/// and present it. +fn build_window(app: &adw::Application) { + let dirs = match AppDirs::new() { + Ok(d) => d, + Err(e) => { + tracing::error!("cannot resolve app dirs: {e}"); + return; + } + }; + + let stack = adw::ViewStack::new(); + let (login_page, login_widgets) = build_login_page(); + let (main_page, main_widgets) = build_main_page(); + let (browser_page, browser_widgets) = build_browser_page(); + let (gallery_page, gallery_widgets) = build_gallery_page(); + let (shared_page, shared_widgets) = build_shared_page(); + let (shared_by_me_page, shared_by_me_widgets) = build_shared_by_me_page(); + let (devices_page, devices_widgets) = build_devices_page(); + let (activity_page, activity_widgets) = build_activity_page(); + let (trash_page, trash_widgets) = build_trash_page(); + stack.add_named(&login_page, Some("login")); + stack.add_named(&main_page, Some("main")); + stack.add_named(&browser_page, Some("browser")); + stack.add_named(&gallery_page, Some("gallery")); + stack.add_named(&shared_by_me_page, Some("sharedbyme")); + stack.add_named(&shared_page, Some("shared")); + stack.add_named(&devices_page, Some("devices")); + stack.add_named(&activity_page, Some("activity")); + stack.add_named(&trash_page, Some("trash")); + + // Sidebar: the signed-in destinations. Selecting a row swaps the page stack; + // `sync_sidebar` pushes the other way when navigation happens elsewhere (e.g. + // login lands on Files). + let (sidebar_page, sidebar_list) = build_sidebar(); + + // Header spinner, hidden until a background open/load is in flight. + let spinner = gtk4::Spinner::new(); + spinner.set_visible(false); + let header = adw::HeaderBar::new(); + header.pack_end(&build_primary_menu()); + header.pack_end(&spinner); + let toolbar = adw::ToolbarView::new(); + toolbar.add_top_bar(&header); + toolbar.set_content(Some(&stack)); + + let content_page = adw::NavigationPage::builder() + .title("Proton Drive") + .child(&toolbar) + .build(); + let split = adw::NavigationSplitView::builder() + .sidebar(&sidebar_page) + .content(&content_page) + .min_sidebar_width(200.0) + .max_sidebar_width(240.0) + .build(); + + // Toasts float over everything, so a report from a background action reaches + // the user whichever page they're on. + let toasts = adw::ToastOverlay::new(); + toasts.set_child(Some(&split)); + + let ui = Rc::new(Ui { + dirs, + stack: stack.clone(), + toasts: toasts.clone(), + spinner: spinner.clone(), + busy: Cell::new(0), + opening: RefCell::new(HashSet::new()), + session: RefCell::new(auth::load().ok()), + mounted: RefCell::new(false), + sidebar: sidebar_list.clone(), + nav: split.clone(), + login: LoginState { + email: login_widgets.0, + password: login_widgets.1, + login_button: login_widgets.2, + login_status: login_widgets.3, + }, + status: StatusState { + status_inflight: Cell::new(false), + account_row: main_widgets.account_row.clone(), + mount_row: main_widgets.mount_row.clone(), + transfers_group: main_widgets.transfers_group.clone(), + transfer_rows: RefCell::new(Vec::new()), + transfers_inflight: Cell::new(false), + 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), + notified_mounted: Cell::new(None), + active_transfers: Cell::new(0), + }, + browser: BrowserState { + model: browser_widgets.model.clone(), + back: browser_widgets.back.clone(), + crumb: browser_widgets.crumb.clone(), + content: browser_widgets.content.clone(), + status: browser_widgets.status.clone(), + retry: browser_widgets.retry.clone(), + split: browser_widgets.split.clone(), + path: RefCell::new(String::new()), + search: browser_widgets.search.clone(), + new_folder: browser_widgets.new_folder.clone(), + upload: browser_widgets.upload.clone(), + upload_folder: browser_widgets.upload_folder.clone(), + search_source: RefCell::new(None), + }, + details: DetailsState { + details: browser_widgets.details, + details_entry: RefCell::new(None), + details_suppress: Cell::new(false), + grid_selection: browser_widgets.grid_selection.clone(), + list_selection: browser_widgets.list_selection.clone(), + }, + trash: TrashState { + model: trash_widgets.model.clone(), + content: trash_widgets.content.clone(), + status: trash_widgets.status.clone(), + retry: trash_widgets.retry.clone(), + empty: trash_widgets.empty.clone(), + subtitle: trash_widgets.subtitle.clone(), + }, + gallery: GalleryState { + model: gallery_widgets.model.clone(), + groups: gallery_widgets.groups.clone(), + tile: Cell::new(TILE_DEFAULT), + content: gallery_widgets.content.clone(), + status: gallery_widgets.status.clone(), + retry: gallery_widgets.retry.clone(), + more: gallery_widgets.more.clone(), + upload: gallery_widgets.upload.clone(), + subtitle: gallery_widgets.subtitle.clone(), + kind: Cell::new(None), + tabs: gallery_widgets.tabs.clone(), + dates: gallery_widgets.dates.clone(), + date_ranges: RefCell::new(vec![None]), + range: Cell::new(None), + date_suppress: Cell::new(false), + loading: Cell::new(false), + width: Cell::new(0), + photo_tex: RefCell::new(HashMap::new()), + photo_tex_order: RefCell::new(VecDeque::new()), + photo_ratio: RefCell::new(HashMap::new()), + photo_ratio_dirty: Cell::new(false), + photo_nothumb: RefCell::new(HashSet::new()), + thumb_wanted: RefCell::new(HashMap::new()), + thumb_queue: RefCell::new(VecDeque::new()), + thumb_inflight: Cell::new(false), + decode_queue: RefCell::new(VecDeque::new()), + decode_idle: Cell::new(false), + thumb_source: RefCell::new(None), + relayout_source: RefCell::new(None), + bound: RefCell::new(HashMap::new()), + }, + shared: SharedState { + content: shared_widgets.content.clone(), + status: shared_widgets.status.clone(), + retry: shared_widgets.retry.clone(), + with_me_group: shared_widgets.shared_with_me.clone(), + invitations_group: shared_widgets.invitations.clone(), + bookmarks_group: shared_widgets.bookmarks.clone(), + rows: RefCell::new(Vec::new()), + inflight: Cell::new(false), + loaded_at: Cell::new(None), + }, + shared_by_me: SharedByMeState { + content: shared_by_me_widgets.content.clone(), + status: shared_by_me_widgets.status.clone(), + retry: shared_by_me_widgets.retry.clone(), + group: shared_by_me_widgets.group.clone(), + rows: RefCell::new(Vec::new()), + inflight: Cell::new(false), + loaded_at: Cell::new(None), + }, + devices: DevicesState { + content: devices_widgets.content.clone(), + status: devices_widgets.status.clone(), + retry: devices_widgets.retry.clone(), + group: devices_widgets.group.clone(), + rows: RefCell::new(Vec::new()), + sync_group: devices_widgets.sync_group.clone(), + sync_rows: RefCell::new(Vec::new()), + inflight: Cell::new(false), + loaded_at: Cell::new(None), + }, + activity: ActivityState { + content: activity_widgets.content.clone(), + status: activity_widgets.status.clone(), + retry: activity_widgets.retry.clone(), + group: activity_widgets.group.clone(), + rows: RefCell::new(Vec::new()), + inflight: Cell::new(false), + key: RefCell::new(None), + }, + }); + ui.load_ratios(); + + wire_login(&ui); + wire_logout(&ui, &main_widgets.logout_button); + wire_settings( + &ui, + &main_widgets.purge_button, + &main_widgets.mountpoint_button, + ); + wire_sidebar(&ui); + wire_browser(&ui, &browser_widgets.grid, &browser_widgets.column_view); + wire_browser_actions( + &ui, + &browser_widgets.new_folder, + &browser_widgets.upload, + &browser_widgets.upload_folder, + ); + wire_details(&ui); + wire_search(&ui); + wire_gallery(&ui, &gallery_widgets.list, &gallery_widgets.scroll); + wire_trash(&ui, &trash_widgets.list, &trash_widgets.empty); + wire_shared(&ui, &shared_widgets.retry, &shared_widgets.add_bookmark); + wire_shared_by_me(&ui, &shared_by_me_widgets.retry); + wire_devices(&ui, &devices_widgets.retry, &devices_widgets.add_folder); + wire_activity(&ui, &activity_widgets.retry); + wire_refresh( + &ui, + &[ + &browser_widgets.refresh, + &gallery_widgets.refresh, + &trash_widgets.refresh, + &shared_widgets.refresh, + &shared_by_me_widgets.refresh, + &devices_widgets.refresh, + &activity_widgets.refresh, + ], + ); + wire_retry(&ui); + + // Lazily load the Files / Photos pages the first time they're shown, so the + // network round-trip only happens on demand rather than on every refresh. + let ui_nav = ui.clone(); + stack.connect_visible_child_name_notify(move |st| { + sync_sidebar(&ui_nav); + match st.visible_child_name().as_deref() { + Some("browser") => load_browser(&ui_nav), + Some("gallery") => load_gallery(&ui_nav, false), + // Network-backed pages skip the fetch (and the "Loading…" flash) when + // the rows on screen are still fresh; the Retry button and mutations + // invalidate the timestamp to force a reload. + Some("sharedbyme") if page_fresh(&ui_nav.shared_by_me.loaded_at) => {} + Some("sharedbyme") => load_shared_by_me(&ui_nav), + Some("shared") if page_fresh(&ui_nav.shared.loaded_at) => {} + Some("shared") => load_shared(&ui_nav), + Some("devices") if page_fresh(&ui_nav.devices.loaded_at) => {} + Some("devices") => load_devices(&ui_nav), + // Activity is intentionally not TTL-cached: it changes out from under + // the page as background uploads and edits complete, so it reloads on + // every visit to stay live. + Some("activity") => load_activity(&ui_nav), + Some("trash") => load_trash(&ui_nav), + _ => {} + } + }); + + let window = adw::ApplicationWindow::builder() + .application(app) + .title("Proton Drive") + .default_width(980) + .default_height(680) + .content(&toasts) + .build(); + install_shortcuts(&ui, &window); + install_window_actions(&window); + + refresh(&ui); + // Periodic refresh while the window lives. The closure holds a strong `Rc`; + // it is dropped when the source is removed on window close. + let ui_tick = ui.clone(); + let source = glib::timeout_add_local(REFRESH_INTERVAL, move || { + refresh(&ui_tick); + glib::ControlFlow::Continue + }); + let cell = RefCell::new(Some(source)); + let ui_close = ui.clone(); + window.connect_close_request(move |_| { + if let Some(id) = cell.borrow_mut().take() { + id.remove(); + } + ui_close.save_ratios(); + glib::Propagation::Proceed + }); + + window.present(); +} + +/// The sidebar destinations, in order: the row index is the index into this table, +/// and each entry is `(stack page name, label, icon)`. +const DESTINATIONS: [(&str, &str, &str); 8] = [ + ("browser", "My files", "folder-symbolic"), + ("sharedbyme", "Shared", "emblem-shared-symbolic"), + ("shared", "Shared with me", "system-users-symbolic"), + ("devices", "Computers", "computer-symbolic"), + ("gallery", "Photos", "image-x-generic-symbolic"), + ("activity", "Activity", "document-open-recent-symbolic"), + ("trash", "Trash", "user-trash-symbolic"), + ("main", "Settings", "emblem-system-symbolic"), +]; + +/// The navigation sidebar: a Proton-branded header over one row per destination. +/// Returns the page (for the split view) and the list (to drive + reflect the +/// current page). +fn build_sidebar() -> (adw::NavigationPage, gtk4::ListBox) { + let list = gtk4::ListBox::new(); + list.set_selection_mode(gtk4::SelectionMode::Single); + list.add_css_class("navigation-sidebar"); + for (_, label, icon) in DESTINATIONS { + let row = adw::ActionRow::builder().title(label).build(); + row.add_prefix(>k4::Image::from_icon_name(icon)); + list.append(&row); + } + + let brand = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + let icon = gtk4::Image::from_icon_name("folder-remote-symbolic"); + icon.add_css_class("brand-icon"); + brand.append(&icon); + brand.append(>k4::Label::new(Some("Proton Drive"))); + + let header = adw::HeaderBar::new(); + header.set_title_widget(Some(&brand)); + + let scroll = gtk4::ScrolledWindow::builder() + .hscrollbar_policy(gtk4::PolicyType::Never) + .vexpand(true) + .child(&list) + .build(); + let toolbar = adw::ToolbarView::new(); + toolbar.add_top_bar(&header); + toolbar.set_content(Some(&scroll)); + + let page = adw::NavigationPage::builder() + .title("Proton Drive") + .child(&toolbar) + .build(); + (page, list) +} + +/// Selecting a sidebar row navigates the page stack. The reverse direction (stack +/// → sidebar highlight) is [`sync_sidebar`], so the two can't fight: this handler +/// only ever writes the stack. +fn wire_sidebar(ui: &Rc) { + let ui_row = ui.clone(); + ui.sidebar.connect_row_selected(move |_, row| { + let Some(row) = row else { return }; + let Some((page, _, _)) = DESTINATIONS.get(row.index() as usize) else { + return; + }; + if ui_row.stack.visible_child_name().as_deref() != Some(*page) { + ui_row.stack.set_visible_child_name(page); + } + }); +} + +/// Whether a network-backed page painted good data within [`PAGE_TTL`] and so +/// can be reused without re-fetching. A `None` timestamp (never loaded, or +/// invalidated by a mutation) is always stale. +fn page_fresh(loaded_at: &Cell>) -> bool { + loaded_at.get().is_some_and(|t| t.elapsed() < PAGE_TTL) +} + +/// A page header's Refresh button. Every page that can show stale rows carries +/// one, so the user never has to guess whether what they're looking at is +/// current or wait out a TTL they can't see. +fn refresh_button() -> gtk4::Button { + let button = gtk4::Button::builder() + .icon_name("view-refresh-symbolic") + .tooltip_text("Refresh (F5)") + .valign(gtk4::Align::Center) + .build(); + button.add_css_class("flat"); + button +} + +/// Point every page's Refresh button at the current page. One handler for all of +/// them: the button acts on whatever is on screen, so it can't refresh a page the +/// user has since navigated away from. +fn wire_refresh(ui: &Rc, buttons: &[>k4::Button]) { + for button in buttons { + let ui = ui.clone(); + button.connect_clicked(move |_| reload_current_page(&ui)); + } +} + +/// Re-fetch the visible page from the server, bypassing every layer of cache +/// between it and the account. +/// +/// The two layers are separate: the daemon's own persisted listings (folders, +/// trash, photos) are dropped with [`Request::Refresh`] before re-asking, while +/// the pages the daemon always fetches live (sharing, devices, activity) only +/// need this front-end's [`PAGE_TTL`] stamp cleared. +fn reload_current_page(ui: &Rc) { + match ui.stack.visible_child_name().as_deref() { + Some("browser") => { + let path = ui.browser.path.borrow().clone(); + refresh_then(ui, RefreshScope::Dir { path }, load_browser); + } + Some("gallery") => refresh_then(ui, RefreshScope::Photos, |ui| load_gallery(ui, false)), + Some("trash") => refresh_then(ui, RefreshScope::Trash, load_trash), + Some("shared") => { + ui.shared.loaded_at.set(None); + load_shared(ui); + } + Some("sharedbyme") => { + ui.shared_by_me.loaded_at.set(None); + load_shared_by_me(ui); + } + Some("devices") => { + ui.devices.loaded_at.set(None); + load_devices(ui); + } + Some("activity") => load_activity(ui), + _ => {} + } +} + +/// Drop a daemon-side cached listing, then run the page's loader to re-fetch it. +/// +/// The loader runs even when the invalidation failed: it is the loader that knows +/// how to report an unreachable daemon on its own page, and a refresh that fails +/// silently would read as a dead button. +fn refresh_then(ui: &Rc, scope: RefreshScope, load: fn(&Rc)) { + let rx = spawn_request(ui.dirs.control_socket(), Request::Refresh { scope }); + let ui = ui.clone(); + glib::spawn_future_local(async move { + let _ = rx.recv().await; + load(&ui); + }); +} + +/// Highlight the sidebar row for whichever page the stack is showing, so +/// navigation that doesn't start in the sidebar (login landing on Files, the tray +/// raising the window) still moves the selection. +fn sync_sidebar(ui: &Rc) { + let Some(current) = ui.stack.visible_child_name() else { + return; + }; + let index = DESTINATIONS + .iter() + .position(|(page, _, _)| *page == current); + match index.and_then(|i| ui.sidebar.row_at_index(i as i32)) { + Some(row) => { + if ui.sidebar.selected_row().as_ref() != Some(&row) { + ui.sidebar.select_row(Some(&row)); + } + } + // The login page has no destination row. + None => ui.sidebar.unselect_all(), + } +} + +/// Show a transient toast. Non-blocking by design: an action's outcome is +/// reported without stealing focus or forcing a click, so the user can keep +/// working while a slow upload lands. +fn toast(ui: &Rc, message: &str) { + ui.toasts.add_toast(adw::Toast::new(message)); +} + +/// Show a toast for a failure. Same surface as [`toast`], but the message is +/// prefixed with what was being attempted, since a bare daemon error ("no such +/// file") reads as noise without it. +fn toast_error(ui: &Rc, what: &str, detail: &str) { + let detail = detail.trim(); + let message = if detail.is_empty() { + what.to_string() + } else { + format!("{what}: {detail}") + }; + tracing::warn!("{message}"); + let toast = adw::Toast::builder().title(&message).timeout(6).build(); + ui.toasts.add_toast(toast); +} + +/// Headline for a failed request, chosen from its [`ErrorKind`] rather than from +/// the text the daemon happened to assemble. +/// +/// The daemon's prose names the layer that failed (`"enumerate nodes: …"`), which +/// is right for a log and wrong for a user. `kind` is the part that says what the +/// person in front of the screen should understand, so the headline comes from it +/// and the prose is demoted to the detail line. +/// +/// `fallback` is the caller's own description of the action ("Couldn't rename"), +/// used where the class carries no better wording than the caller already has. +fn error_headline(kind: ErrorKind, fallback: &str) -> &str { + match kind { + ErrorKind::Offline => "You're offline", + ErrorKind::NotFound => "That's not there any more", + ErrorKind::Denied => "You don't have access to that", + ErrorKind::Conflict => "Something changed this first", + ErrorKind::Invalid | ErrorKind::Remote | ErrorKind::Internal => fallback, + } +} + +/// Report a failed request, letting its class pick the wording. +/// +/// Prefer this to [`toast_error`] anywhere a [`Response::Error`] is being shown: +/// being offline is the common case and deserves to read as a state of the +/// network rather than as a fault in whatever the user just did. +fn toast_failure(ui: &Rc, what: &str, message: &str, kind: ErrorKind) { + match kind { + // The detail here is always some inner layer's EIO. Nothing in it helps. + ErrorKind::Offline => toast_error(ui, error_headline(kind, what), ""), + _ => toast_error(ui, error_headline(kind, what), message), + } +} + +/// The header's primary (hamburger) menu: the app-level entries that don't belong +/// on any one page. +fn build_primary_menu() -> gtk4::MenuButton { + let menu = gio::Menu::new(); + menu.append(Some("Keyboard Shortcuts"), Some("win.shortcuts")); + menu.append(Some("About Proton Drive"), Some("win.about")); + gtk4::MenuButton::builder() + .icon_name("open-menu-symbolic") + .tooltip_text("Main menu") + .menu_model(&menu) + .build() +} + +/// Back the primary menu's entries with window actions. +fn install_window_actions(window: &adw::ApplicationWindow) { + let shortcuts = gio::SimpleAction::new("shortcuts", None); + let win = window.clone(); + shortcuts.connect_activate(move |_, _| show_shortcuts(&win)); + window.add_action(&shortcuts); + + let about = gio::SimpleAction::new("about", None); + let win = window.clone(); + about.connect_activate(move |_, _| { + let dialog = adw::AboutDialog::builder() + .application_name("Proton Drive") + .application_icon("folder-remote-symbolic") + .version(pdfs_core::config::APP_VERSION) + .developer_name("proton-drive-linux") + .comments("On-demand Proton Drive sync for Linux.") + .build(); + dialog.present(Some(&win)); + }); + window.add_action(&about); +} + +/// The keyboard-shortcut cheatsheet behind the menu entry, listing exactly what +/// [`install_shortcuts`] binds. +fn show_shortcuts(window: &adw::ApplicationWindow) { + const KEYS: [(&str, &str); 6] = [ + ("Ctrl+F", "Search Drive"), + ("Ctrl+N", "New folder"), + ("Ctrl+U", "Upload file"), + ("F2", "Rename selection"), + ("Delete", "Move selection to Trash"), + ("Escape", "Close the details pane"), + ]; + let group = adw::PreferencesGroup::builder().title("Files").build(); + for (keys, action) in KEYS { + let row = adw::ActionRow::builder().title(action).build(); + let label = gtk4::Label::builder() + .label(keys) + .valign(gtk4::Align::Center) + .build(); + label.add_css_class("dim-label"); + label.add_css_class("monospace"); + row.add_suffix(&label); + group.add(&row); + } + let page = adw::PreferencesPage::new(); + page.add(&group); + + let dialog = adw::Dialog::builder() + .title("Keyboard Shortcuts") + .content_width(420) + .child(&{ + let toolbar = adw::ToolbarView::new(); + toolbar.add_top_bar(&adw::HeaderBar::new()); + toolbar.set_content(Some(&page)); + toolbar + }) + .build(); + dialog.present(Some(window)); +} + +/// Window-level keyboard shortcuts, so the browser is usable without the mouse: +/// Ctrl+F focuses search, Ctrl+N makes a folder, Ctrl+U uploads, F2 renames and +/// Delete trashes the selected entry, Escape closes the details pane. +fn install_shortcuts(ui: &Rc, window: &adw::ApplicationWindow) { + let controller = gtk4::EventControllerKey::new(); + let ui = ui.clone(); + controller.connect_key_pressed(move |_, key, _, state| { + let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK); + let on_browser = ui.stack.visible_child_name().as_deref() == Some("browser"); + match key { + // Refresh works on every page, so it is matched before the + // browser-only bindings. + gtk4::gdk::Key::F5 => reload_current_page(&ui), + gtk4::gdk::Key::r | gtk4::gdk::Key::R if ctrl => reload_current_page(&ui), + gtk4::gdk::Key::f | gtk4::gdk::Key::F if ctrl && on_browser => { + ui.browser.search.grab_focus(); + } + gtk4::gdk::Key::n | gtk4::gdk::Key::N if ctrl && on_browser => prompt_new_folder(&ui), + gtk4::gdk::Key::u | gtk4::gdk::Key::U if ctrl && on_browser => prompt_upload(&ui), + gtk4::gdk::Key::F2 if on_browser => { + if let Some(entry) = selected_entry(&ui) { + prompt_rename(&ui, &entry); + } + } + gtk4::gdk::Key::Delete if on_browser => { + if let Some(entry) = selected_entry(&ui) { + prompt_delete(&ui, &entry); + } + } + gtk4::gdk::Key::Escape if on_browser && ui.browser.split.shows_sidebar() => { + hide_details(&ui); + } + _ => return glib::Propagation::Proceed, + } + glib::Propagation::Stop + }); + window.add_controller(controller); +} + +/// Send a desktop notification through the app's GIO channel. `id` replaces any +/// earlier notification with the same id, so a flapping mount updates one +/// notification instead of stacking a column of them. +fn notify(id: &str, title: &str, body: &str) { + let Some(app) = gio::Application::default() else { + return; + }; + let notification = gio::Notification::new(title); + notification.set_body(Some(body)); + notification.set_priority(gio::NotificationPriority::Low); + app.send_notification(Some(id), ¬ification); +} + +/// Run one blocking control-socket round-trip on a worker thread, returning a +/// channel that yields the [`Response`] once. Browser/gallery requests reach the +/// network through the daemon, so they must not block the GTK main loop the way +/// the cheap [`Request::Status`] poll in [`refresh`] can. +fn spawn_request( + socket: PathBuf, + req: Request, +) -> async_channel::Receiver> { + let (tx, rx) = async_channel::bounded(1); + std::thread::spawn(move || { + let result = send(&socket, &req).map_err(|e| e.to_string()); + let _ = tx.send_blocking(result); + }); + rx +} + +/// The top-level window, for parenting dialogs. +fn ui_window(ui: &Rc) -> Option { + ui.stack.root().and_downcast::() +} + +/// A dim, non-interactive placeholder row for an empty section. +fn dim_row(text: &str) -> adw::ActionRow { + let row = adw::ActionRow::builder().title(text).build(); + row.add_css_class("dim-label"); + row.set_activatable(false); + row +} + +/// Open a URL in the user's default browser. +fn open_uri(url: &str) { + if let Err(e) = gio::AppInfo::launch_default_for_uri(url, None::<&gio::AppLaunchContext>) { + tracing::warn!("open uri {url}: {e}"); + } +} + +/// Uppercase the first character of a role word for read-only display. +fn capitalize(s: &str) -> String { + let mut chars = s.chars(); + match chars.next() { + Some(c) => c.to_uppercase().collect::() + chars.as_str(), + None => String::new(), + } +} + +/// Open a local path with the user's default handler. +fn open_path(path: &str) { + if let Err(e) = Command::new("xdg-open").arg(path).spawn() { + tracing::error!("xdg-open {path} failed: {e}"); + } +} + +/// Format a byte count as a short binary-unit string (e.g. `1.2 GiB`). +fn human_bytes(bytes: u64) -> String { + const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"]; + if bytes == 0 { + return "0 B".into(); + } + let mut value = bytes as f64; + let mut unit = 0; + while value >= 1024.0 && unit < UNITS.len() - 1 { + value /= 1024.0; + unit += 1; + } + if unit == 0 { + format!("{bytes} B") + } else { + format!("{value:.1} {}", UNITS[unit]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A justified row must fill the content width exactly: tile widths plus the + /// gaps between them add up to the width, with nothing left ragged. + fn row_width(row: &[(i32, i32)]) -> i32 { + let gaps = TILE_GAP * (row.len().saturating_sub(1)) as i32; + row.iter().map(|(width, _)| *width).sum::() + gaps + } + + #[test] + fn justified_rows_fill_the_width_exactly() { + let ratios = [ + 1.5, 0.75, 1.5, 1.33, 0.66, 1.5, 1.0, 2.2, 1.5, 0.8, 1.5, 1.6, + ]; + let rows = plan_rows(&ratios, 180.0, 1000.0); + + assert!( + rows.len() > 1, + "12 photos at 180px should need several rows" + ); + // Every row but the trailing one is justified. + for row in &rows[..rows.len() - 1] { + assert_eq!(row_width(row), 1000); + } + } + + #[test] + fn every_photo_lands_in_exactly_one_row() { + let ratios: Vec = (0..37).map(|i| 0.5 + f64::from(i % 5) * 0.4).collect(); + let rows = plan_rows(&ratios, 180.0, 1000.0); + let tiles: usize = rows.iter().map(Vec::len).sum(); + assert_eq!(tiles, ratios.len()); + } + + #[test] + fn tiles_keep_their_aspect_ratio() { + let rows = plan_rows(&[1.5, 1.5, 1.5, 1.5, 1.5, 1.5, 1.5, 1.5], 180.0, 1000.0); + // All but the last tile of a justified row are sized straight from the + // ratio, so w/h is the photo's own — no cropping needed to place it. + for row in &rows { + for (width, height) in &row[..row.len() - 1] { + let ratio = f64::from(*width) / f64::from(*height); + assert!((ratio - 1.5).abs() < 0.02, "{width}x{height} is not 3:2"); + } + } + } + + #[test] + fn trailing_row_is_not_stretched() { + // Two photos can't fill a 1000px row at 180px tall, so they must keep the + // target height rather than being blown up to a screen-wide row. + let rows = plan_rows(&[1.5, 1.5], 180.0, 1000.0); + assert_eq!(rows.len(), 1); + assert!(rows[0].iter().all(|(_, height)| *height == 180)); + assert!(row_width(&rows[0]) < 1000); + } + + #[test] + fn an_extreme_panorama_cannot_squash_its_row() { + // Unclamped, a 20:1 panorama would drag its whole row down to ~40px tall. + // Clamped at RATIO_MAX it stays a wide tile in a row of normal height. + let rows = plan_rows(&[20.0, 1.5, 1.5], 180.0, 1000.0); + let (width, height) = rows[0][0]; + assert!(height > 120, "row squashed to {height}px"); + assert!(width <= 1000, "tile overflows the row at {width}px"); + assert_eq!(row_width(&rows[0]), 1000); + } + + #[test] + fn narrow_widths_and_no_photos_are_survivable() { + assert!(plan_rows(&[], 180.0, 1000.0).is_empty()); + // Width below the floor is clamped rather than producing zero-px tiles. + let rows = plan_rows(&[1.5, 1.5], 180.0, 0.0); + assert!( + rows.iter() + .flatten() + .all(|(width, height)| *width > 0 && *height > 0) + ); + } +} diff --git a/crates/pdfs-gui/src/app/pages/activity.rs b/crates/pdfs-gui/src/app/pages/activity.rs new file mode 100644 index 0000000..3bc9d60 --- /dev/null +++ b/crates/pdfs-gui/src/app/pages/activity.rs @@ -0,0 +1,305 @@ +use crate::*; + +pub(crate) struct ActivityState { + // Activity page: one section, newest-first, rebuilt wholesale on each load. + pub(crate) content: gtk4::Stack, + pub(crate) status: adw::StatusPage, + pub(crate) retry: gtk4::Button, + pub(crate) group: adw::PreferencesGroup, + pub(crate) rows: RefCell>, + pub(crate) inflight: Cell, + /// Fingerprint of the feed currently on screen (see [`activity_key`]). The + /// page polls every couple of seconds and usually gets back exactly what it + /// is already showing; rebuilding all 200 rows for that would throw away the + /// user's scroll position several times a minute. + pub(crate) key: RefCell>, +} + +/// Widgets the Activity page's load/repaint touch. +pub(crate) struct ActivityWidgets { + pub(crate) content: gtk4::Stack, + pub(crate) status: adw::StatusPage, + pub(crate) group: adw::PreferencesGroup, + pub(crate) retry: gtk4::Button, + pub(crate) refresh: gtk4::Button, +} + +/// The Activity page: a newest-first feed of the mutations and transfers the +/// daemon performed this session (uploads, deletes, shares, …). +pub(crate) fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) { + let title = gtk4::Label::builder() + .label("Activity") + .halign(gtk4::Align::Start) + .hexpand(true) + .build(); + title.add_css_class("title-2"); + let refresh = refresh_button(); + let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + header.set_hexpand(true); + header.append(&title); + header.append(&refresh); + + let group = adw::PreferencesGroup::new(); + let clamp = adw::Clamp::builder().child(&group).build(); + let scroll = gtk4::ScrolledWindow::builder() + .vexpand(true) + .child(&clamp) + .build(); + + let retry = gtk4::Button::builder() + .label("Retry") + .halign(gtk4::Align::Center) + .build(); + retry.add_css_class("pill"); + retry.add_css_class("suggested-action"); + retry.set_visible(false); + let status = adw::StatusPage::builder() + .icon_name("document-open-recent-symbolic") + .vexpand(true) + .child(&retry) + .build(); + status.add_css_class("compact"); + + let content = gtk4::Stack::new(); + content.set_vexpand(true); + content.set_transition_type(gtk4::StackTransitionType::Crossfade); + content.add_named(&scroll, Some("list")); + content.add_named(&status, Some("status")); + + let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12); + inner.set_margin_top(12); + inner.set_margin_bottom(12); + inner.set_margin_start(12); + inner.set_margin_end(12); + inner.append(&header); + inner.append(&content); + + ( + inner.upcast(), + ActivityWidgets { + content, + status, + group, + retry, + refresh, + }, + ) +} + +/// Install the Activity page's retry button. +pub(crate) fn wire_activity(ui: &Rc, retry: >k4::Button) { + let ui_retry = ui.clone(); + retry.connect_clicked(move |_| { + service::restart(); + load_activity(&ui_retry); + }); +} + +/// Show a status page in place of the Activity list. +pub(crate) fn activity_status(ui: &Rc, icon: &str, title: &str, description: &str, retry: bool) { + ui.activity.status.set_icon_name(Some(icon)); + ui.activity.status.set_title(title); + ui.activity.status.set_description(Some(description)); + ui.activity.retry.set_visible(retry); + ui.activity.content.set_visible_child_name("status"); + // The list is no longer what is on screen, so the next repaint must not skip + // itself as a no-op and leave this status view up. + *ui.activity.key.borrow_mut() = None; +} + +/// Refresh the Activity feed in place, with no status flash and no spinner. +/// Driven by the periodic tick while the page is on screen, so a running sync +/// pass fills the feed as it works rather than only once it is done. Anything +/// other than a good answer leaves the rows alone until the next tick. +pub(crate) fn refresh_activity(ui: &Rc) { + if ui.activity.inflight.get() { + return; + } + ui.activity.inflight.set(true); + let rx = spawn_request( + ui.dirs.control_socket(), + Request::ListActivity { limit: 200 }, + ); + let ui = ui.clone(); + glib::spawn_future_local(async move { + let result = rx.recv().await; + ui.activity.inflight.set(false); + // The page may have been navigated away from while the request was in + // flight. + if let Ok(Ok(Response::Activity { items })) = result + && ui.stack.visible_child_name().as_deref() == Some("activity") + { + repaint_activity(&ui, &items); + } + }); +} + +pub(crate) fn load_activity(ui: &Rc) { + if ui.activity.inflight.get() { + return; + } + ui.activity.inflight.set(true); + activity_status( + ui, + "document-open-recent-symbolic", + "Loading…", + "Reading recent activity.", + false, + ); + ui.busy_begin(); + let rx = spawn_request( + ui.dirs.control_socket(), + Request::ListActivity { limit: 200 }, + ); + let ui = ui.clone(); + glib::spawn_future_local(async move { + let result = rx.recv().await; + ui.busy_end(); + ui.activity.inflight.set(false); + match result { + Ok(Ok(Response::Activity { items })) => repaint_activity(&ui, &items), + Ok(Ok(Response::Error { message, .. })) => activity_status( + &ui, + "dialog-warning-symbolic", + "Couldn't read activity", + &message, + false, + ), + Ok(Ok(_)) => activity_status( + &ui, + "dialog-warning-symbolic", + "Couldn't read activity", + "Unexpected reply from the mount service.", + false, + ), + Ok(Err(_)) | Err(_) => activity_unreachable(&ui), + } + }); +} + +/// The daemon didn't answer the Activity page. +pub(crate) fn activity_unreachable(ui: &Rc) { + if service::is_failed() || !service::is_active() { + activity_status( + ui, + "network-offline-symbolic", + "Not connected", + "The Proton Drive mount service isn't running.", + true, + ); + return; + } + activity_status( + ui, + "folder-remote-symbolic", + "Connecting…", + "Waiting for the Proton Drive mount service to come up.", + false, + ); + let ui = ui.clone(); + glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || { + if ui.stack.visible_child_name().as_deref() == Some("activity") { + load_activity(&ui); + } + }); +} + +/// A cheap fingerprint of a feed: how many entries, and what the newest one is. +/// The log is append-only and newest-first, so a feed with the same length and +/// the same newest entry is the same feed. +pub(crate) fn activity_key(items: &[ActivityEntry]) -> String { + match items.first() { + Some(a) => format!("{}:{}:{}:{}", items.len(), a.time, a.target, a.detail), + None => String::new(), + } +} + +/// Rebuild the Activity section from a fresh log, unless it is already showing +/// exactly this feed. +pub(crate) fn repaint_activity(ui: &Rc, items: &[ActivityEntry]) { + let key = activity_key(items); + if ui.activity.key.borrow().as_deref() == Some(key.as_str()) { + return; + } + *ui.activity.key.borrow_mut() = Some(key); + for row in ui.activity.rows.borrow_mut().drain(..) { + ui.activity.group.remove(&row); + } + if items.is_empty() { + activity_status( + ui, + "document-open-recent-symbolic", + "Nothing yet", + "Uploads, moves, shares and other changes appear here as they happen.", + false, + ); + return; + } + ui.activity.content.set_visible_child_name("list"); + let mut rows: Vec = Vec::new(); + for a in items { + let mut subtitle = activity_time(a.time); + if !a.detail.is_empty() { + subtitle = format!("{subtitle} · {}", a.detail); + } + let row = adw::ActionRow::builder() + .title(format!("{} {}", activity_verb(a.kind), a.target)) + .subtitle(subtitle) + .build(); + let icon = if a.ok { + activity_icon(a.kind) + } else { + "dialog-warning-symbolic" + }; + row.add_prefix(>k4::Image::from_icon_name(icon)); + row.set_activatable(false); + ui.activity.group.add(&row); + rows.push(row.upcast()); + } + *ui.activity.rows.borrow_mut() = rows; +} + +/// A human verb for an activity kind, used as the row title's lead word. +pub(crate) fn activity_verb(kind: ActivityKind) -> &'static str { + match kind { + ActivityKind::Upload => "Uploaded", + ActivityKind::Download => "Downloaded", + ActivityKind::Sync => "Synced", + ActivityKind::Rename => "Renamed", + ActivityKind::Move => "Moved", + ActivityKind::CreateFolder => "Created folder", + ActivityKind::Trash => "Trashed", + ActivityKind::Restore => "Restored", + ActivityKind::DeleteForever => "Deleted", + ActivityKind::EmptyTrash => "Emptied trash —", + ActivityKind::Share => "Shared", + ActivityKind::PublicLink => "Linked", + ActivityKind::Unshare => "Unshared", + } +} + +/// A themed icon for an activity kind. +pub(crate) fn activity_icon(kind: ActivityKind) -> &'static str { + match kind { + ActivityKind::Upload => "document-send-symbolic", + ActivityKind::Download => "document-save-symbolic", + ActivityKind::Sync => "emblem-synchronizing-symbolic", + ActivityKind::Rename => "document-edit-symbolic", + ActivityKind::Move => "go-jump-symbolic", + ActivityKind::CreateFolder => "folder-new-symbolic", + ActivityKind::Trash | ActivityKind::DeleteForever | ActivityKind::EmptyTrash => { + "user-trash-symbolic" + } + ActivityKind::Restore => "edit-undo-symbolic", + ActivityKind::Share | ActivityKind::PublicLink => "emblem-shared-symbolic", + ActivityKind::Unshare => "action-unavailable-symbolic", + } +} + +/// Format an epoch-seconds timestamp for the Activity feed, in local time. +pub(crate) fn activity_time(secs: i64) -> String { + glib::DateTime::from_unix_local(secs) + .and_then(|dt| dt.format("%b %-d, %H:%M")) + .map(|s| s.to_string()) + .unwrap_or_default() +} diff --git a/crates/pdfs-gui/src/app/pages/browser.rs b/crates/pdfs-gui/src/app/pages/browser.rs new file mode 100644 index 0000000..85f215b --- /dev/null +++ b/crates/pdfs-gui/src/app/pages/browser.rs @@ -0,0 +1,1422 @@ +use crate::*; + +pub(crate) struct BrowserState { + // Files (browser) page. + /// Shared model behind the grid and column views; repopulated per directory. + pub(crate) model: gio::ListStore, + pub(crate) back: gtk4::Button, + /// Clickable breadcrumb trail (a button per path segment); rebuilt per load + /// by [`repaint_crumb`] so each ancestor folder navigates on click. + pub(crate) crumb: gtk4::Box, + /// Swaps the Files content area between the grid/list views and the status + /// page below; see [`browser_status`]. + pub(crate) content: gtk4::Stack, + /// The Files empty/loading/error surface, shown in place of the views. + pub(crate) status: adw::StatusPage, + /// Sits in [`Self::status`]; shown when a load failed because the mount + /// service is down (not merely starting); restarts the service and reloads. + pub(crate) retry: gtk4::Button, + /// The details pane's host; `show_sidebar` is what reveals/hides the pane. + pub(crate) split: adw::OverlaySplitView, + /// Mountpoint-relative path the browser is showing (empty = root). + pub(crate) path: RefCell, + /// Debounced full-text search box in the browser header. + pub(crate) search: gtk4::SearchEntry, + /// The folder-level actions, insensitive while the mount is down: without a + /// daemon they can only fail, and a greyed button says so before the click. + pub(crate) new_folder: gtk4::Button, + pub(crate) upload: gtk4::Button, + pub(crate) upload_folder: gtk4::Button, + /// Pending debounce timer for the search box; replaced on every keystroke so + /// only the last pause actually fires a [`Request::Search`]. + pub(crate) search_source: RefCell>, +} + +/// Idle pause after the last keystroke before a search query is sent, so typing +/// doesn't fire a request per character. +pub(crate) const SEARCH_DEBOUNCE: Duration = Duration::from_millis(250); + +/// Cap on search hits requested from the daemon. +pub(crate) const SEARCH_LIMIT: usize = 200; + +/// The Files page: a Nautilus-style file manager. A back/breadcrumb header with +/// a grid/list view toggle sits over a [`gtk4::Stack`] that swaps between an +/// **icon grid** ([`gtk4::GridView`]) and a **column list** ([`gtk4::ColumnView`] +/// with Name / Size / Modified columns). Both views are driven by one shared +/// [`gio::ListStore`] of [`BoxedAnyObject`]-wrapped [`DirEntry`]s, so a directory +/// load repopulates the model once and both views update. +/// +/// The factories that render entries — and the columns — need the [`Ui`] handle +/// for activation and the right-click menu, so they're installed later in +/// [`wire_browser`]; this builder only assembles the empty widgets. +/// +/// Empty / loading / error outcomes aren't a label under the header: the whole +/// content area swaps to a centred [`adw::StatusPage`] (see [`browser_status`]), +/// so "this folder is empty" and "the mount is down" read as first-class states +/// rather than a stray line above a blank grid. +pub(crate) struct BrowserWidgets { + pub(crate) model: gio::ListStore, + pub(crate) back: gtk4::Button, + pub(crate) crumb: gtk4::Box, + pub(crate) grid: gtk4::GridView, + pub(crate) column_view: gtk4::ColumnView, + /// Swaps the content area between the grid/list views and the status page. + pub(crate) content: gtk4::Stack, + /// The empty/loading/error surface shown in place of the views. + pub(crate) status: adw::StatusPage, + /// Sits in the status page; shown only when the mount service is down. + pub(crate) retry: gtk4::Button, + pub(crate) search: gtk4::SearchEntry, + pub(crate) new_folder: gtk4::Button, + pub(crate) upload: gtk4::Button, + pub(crate) upload_folder: gtk4::Button, + pub(crate) refresh: gtk4::Button, + /// Wraps the views + the details pane; the pane slides in on selection. + pub(crate) split: adw::OverlaySplitView, + pub(crate) details: DetailsWidgets, + /// The two selection models, so a selection change can drive the details pane + /// and so an action can re-read the entry the user has highlighted. + pub(crate) grid_selection: gtk4::SingleSelection, + pub(crate) list_selection: gtk4::SingleSelection, +} + +pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) { + let model = gio::ListStore::new::(); + + let back = gtk4::Button::builder() + .icon_name("go-previous-symbolic") + .tooltip_text("Up one folder") + .valign(gtk4::Align::Center) + .sensitive(false) + .build(); + back.add_css_class("flat"); + // 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) + .child(&crumb) + .build(); + + // 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 files") + .valign(gtk4::Align::Center) + .build(); + upload.add_css_class("flat"); + let upload_folder = gtk4::Button::builder() + .icon_name("pdfs-folder-upload-symbolic") + .tooltip_text("Upload folder") + .valign(gtk4::Align::Center) + .build(); + upload_folder.add_css_class("flat"); + + // Linked grid/list toggle, top-right, Nautilus-style. + let grid_toggle = gtk4::ToggleButton::builder() + .icon_name("view-grid-symbolic") + .tooltip_text("Grid view") + .active(true) + .build(); + let list_toggle = gtk4::ToggleButton::builder() + .icon_name("view-list-symbolic") + .tooltip_text("List view") + .build(); + list_toggle.set_group(Some(&grid_toggle)); + let toggles = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + toggles.add_css_class("linked"); + toggles.append(&grid_toggle); + toggles.append(&list_toggle); + + let search = gtk4::SearchEntry::builder() + .placeholder_text("Search Drive") + .valign(gtk4::Align::Center) + .build(); + search.set_width_chars(18); + + let refresh = refresh_button(); + + let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + header.append(&back); + header.append(&crumb_scroll); + header.append(&refresh); + header.append(&new_folder); + header.append(&upload); + header.append(&upload_folder); + header.append(&search); + header.append(&toggles); + + // Empty / loading / error surface, shown in place of the views. + let retry = gtk4::Button::builder() + .label("Retry") + .halign(gtk4::Align::Center) + .build(); + retry.add_css_class("pill"); + retry.add_css_class("suggested-action"); + retry.set_visible(false); + let status = adw::StatusPage::builder() + .icon_name("folder-symbolic") + .vexpand(true) + .child(&retry) + .build(); + status.add_css_class("compact"); + + // Icon grid. + let grid_selection = gtk4::SingleSelection::builder() + .model(&model) + .autoselect(false) + .can_unselect(true) + .build(); + let grid = gtk4::GridView::builder() + .model(&grid_selection) + .min_columns(2) + .max_columns(10) + .build(); + grid.add_css_class("file-grid"); + let grid_scroll = gtk4::ScrolledWindow::builder() + .vexpand(true) + .child(&grid) + .build(); + + // Column list. + let list_selection = gtk4::SingleSelection::builder() + .model(&model) + .autoselect(false) + .can_unselect(true) + .build(); + let column_view = gtk4::ColumnView::builder().model(&list_selection).build(); + column_view.add_css_class("data-table"); + let column_scroll = gtk4::ScrolledWindow::builder() + .vexpand(true) + .child(&column_view) + .build(); + + // Stack swapped by the toggle buttons. + let view_stack = gtk4::Stack::new(); + view_stack.set_vexpand(true); + view_stack.add_named(&grid_scroll, Some("grid")); + view_stack.add_named(&column_scroll, Some("list")); + let vs = view_stack.clone(); + grid_toggle.connect_toggled(move |b| { + if b.is_active() { + vs.set_visible_child_name("grid"); + } + }); + let vs = view_stack.clone(); + list_toggle.connect_toggled(move |b| { + if b.is_active() { + vs.set_visible_child_name("list"); + } + }); + + // Outer stack: the views, or the status page when there's nothing to show. + let content = gtk4::Stack::new(); + content.set_vexpand(true); + content.set_transition_type(gtk4::StackTransitionType::Crossfade); + content.add_named(&view_stack, Some("views")); + content.add_named(&status, Some("status")); + + // The details pane slides in from the right when an entry is selected. + let (details_pane, details) = build_details_pane(); + let split = adw::OverlaySplitView::builder() + .sidebar_position(gtk4::PackType::End) + .collapsed(true) + .show_sidebar(false) + .max_sidebar_width(300.0) + .content(&content) + .sidebar(&details_pane) + .build(); + + let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12); + inner.set_margin_top(12); + inner.set_margin_bottom(12); + inner.set_margin_start(12); + inner.set_margin_end(12); + inner.append(&header); + inner.append(&split); + + ( + inner.upcast(), + BrowserWidgets { + model, + back, + crumb, + grid, + column_view, + content, + status, + retry, + search, + new_folder, + upload, + upload_folder, + refresh, + split, + details, + grid_selection, + list_selection, + }, + ) +} + +/// Swap the Files content area to the status page, with a Retry button only when +/// the failure is one the user can act on. +pub(crate) fn browser_status(ui: &Rc, icon: &str, title: &str, description: &str, retry: bool) { + ui.browser.status.set_icon_name(Some(icon)); + ui.browser.status.set_title(title); + ui.browser.status.set_description(Some(description)); + ui.browser.retry.set_visible(retry); + ui.browser.content.set_visible_child_name("status"); + hide_details(ui); +} + +/// Swap the Files content area back to the grid/list views. +pub(crate) fn browser_views(ui: &Rc) { + ui.browser.content.set_visible_child_name("views"); +} + +/// Install the entry factories, columns, activation handlers and the back +/// button. Split out from [`build_browser_page`] because every renderer needs +/// the [`Ui`] handle to open entries and raise the context menu. +pub(crate) fn wire_browser(ui: &Rc, grid: >k4::GridView, column_view: >k4::ColumnView) { + // Back: pop one path segment and reload. + let ui_back = ui.clone(); + ui.browser.back.clone().connect_clicked(move |_| { + { + let mut path = ui_back.browser.path.borrow_mut(); + *path = match path.rfind('/') { + Some(i) => path[..i].to_string(), + None => String::new(), + }; + } + load_browser(&ui_back); + }); + + // Grid tiles: a big icon over an ellipsized name, with a right-click menu. + let factory = gtk4::SignalListItemFactory::new(); + factory.connect_setup({ + let ui = ui.clone(); + 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) + .max_width_chars(13) + .width_chars(13) + .wrap(true) + .lines(2) + .build(); + let tile = gtk4::Box::new(gtk4::Orientation::Vertical, 4); + tile.add_css_class("file-tile"); + 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 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(&entry.name); + apply_badge(&badge, &entry); + }); + grid.set_factory(Some(&factory)); + + let ui_grid = ui.clone(); + grid.connect_activate(move |grid, pos| { + if let Some(entry) = entry_at(grid.model().as_ref(), pos) { + activate_entry(&ui_grid, &entry); + } + }); + + // Column list: Name (icon + label, right-clickable), Size, Modified. + column_view.append_column(&name_column(ui)); + column_view.append_column(&text_column("Size", |e| { + if e.is_dir { + "—".to_string() + } else { + human_bytes(e.size) + } + })); + column_view.append_column(&text_column("Modified", |e| format_modified(e.modified))); + + let ui_col = ui.clone(); + column_view.connect_activate(move |view, pos| { + if let Some(entry) = entry_at(view.model().as_ref(), pos) { + activate_entry(&ui_col, &entry); + } + }); +} + +/// Build the Name column: a small icon plus the (star-prefixed when pinned) name, +/// with the same right-click menu the grid tiles carry. +pub(crate) fn name_column(ui: &Rc) -> gtk4::ColumnViewColumn { + let factory = gtk4::SignalListItemFactory::new(); + factory.connect_setup({ + let ui = ui.clone(); + move |_, item| { + 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)); + } + }); + factory.connect_bind(|_, item| { + let item = item.downcast_ref::().unwrap(); + let cell = item.child().and_downcast::().unwrap(); + let icon = cell.first_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(&entry.name); + apply_badge(&badge, &entry); + }); + let column = gtk4::ColumnViewColumn::new(Some("Name"), Some(factory)); + column.set_expand(true); + column +} + +/// Build a trailing text column whose cell text is derived from each [`DirEntry`] +/// by `render`. +pub(crate) fn text_column( + title: &str, + render: impl Fn(&DirEntry) -> String + 'static, +) -> gtk4::ColumnViewColumn { + let factory = gtk4::SignalListItemFactory::new(); + factory.connect_setup(|_, item| { + let item = item.downcast_ref::().unwrap(); + let label = gtk4::Label::builder().halign(gtk4::Align::Start).build(); + label.add_css_class("dim-label"); + item.set_child(Some(&label)); + }); + factory.connect_bind(move |_, item| { + let item = item.downcast_ref::().unwrap(); + let label = item.child().and_downcast::().unwrap(); + let obj = item.item().and_downcast::().unwrap(); + let entry = obj.borrow::(); + label.set_label(&render(&entry)); + }); + gtk4::ColumnViewColumn::new(Some(title), Some(factory)) +} + +/// Attach a secondary-button [`gtk4::GestureClick`] to a cell that pops a context +/// menu for whatever entry the owning `item` is currently bound to. Capturing the +/// [`gtk4::ListItem`] (rather than a snapshot of the entry) keeps the menu correct +/// as the view recycles cells while scrolling. +pub(crate) fn attach_context_menu(ui: &Rc, item: >k4::ListItem, anchor: >k4::Box) { + let gesture = gtk4::GestureClick::new(); + gesture.set_button(gtk4::gdk::BUTTON_SECONDARY); + let ui = ui.clone(); + let item = item.clone(); + let target = anchor.clone(); + gesture.connect_pressed(move |_, _, x, y| { + if let Some(obj) = item.item().and_downcast::() { + let entry = obj.borrow::().clone(); + show_context_menu(&ui, &entry, &target, x, y); + } + }); + anchor.add_controller(gesture); +} + +/// Pop a context menu next to `anchor` at the click point, offering Open and a +/// Pin/Unpin toggle (files only). Built fresh per click because the items are +/// entry-specific; it unparents itself once dismissed. +pub(crate) fn show_context_menu(ui: &Rc, entry: &DirEntry, anchor: >k4::Box, x: f64, y: f64) { + let menu = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + let popover = gtk4::Popover::builder() + .has_arrow(false) + .position(gtk4::PositionType::Bottom) + .pointing_to(>k4::gdk::Rectangle::new(x as i32, y as i32, 1, 1)) + .child(&menu) + .build(); + popover.set_parent(anchor); + popover.connect_closed(|p| p.unparent()); + + // Videos lead with Play (stream from the mount, no download); the plain Open + // below still downloads a local copy for anyone who wants one. + if is_video_entry(entry) { + let play = menu_item("Play (stream)", "media-playback-start-symbolic"); + let ui_play = ui.clone(); + let entry_play = entry.clone(); + let pop = popover.clone(); + play.connect_clicked(move |_| { + pop.popdown(); + stream_entry(&ui_play, &entry_play); + }); + menu.append(&play); + } + + let open = menu_item("Open", "document-open-symbolic"); + let ui_open = ui.clone(); + let entry_open = entry.clone(); + let pop = popover.clone(); + open.connect_clicked(move |_| { + pop.popdown(); + // Open always means "download a local copy and hand off", even for a + // video — `activate_entry` would otherwise stream it. + if is_video_entry(&entry_open) { + download_and_open(&ui_open, &entry_open); + } else { + activate_entry(&ui_open, &entry_open); + } + }); + menu.append(&open); + + if !entry.is_dir { + let (label, icon) = if entry.pinned { + ("Unpin", "non-starred-symbolic") + } else { + ("Keep offline", "starred-symbolic") + }; + let pin = menu_item(label, icon); + let ui_pin = ui.clone(); + let entry_pin = entry.clone(); + let pop = popover.clone(); + pin.connect_clicked(move |_| { + pop.popdown(); + toggle_pin(&ui_pin, &entry_pin); + }); + 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); + + menu.append(>k4::Separator::new(gtk4::Orientation::Horizontal)); + + let share = menu_item("Share…", "emblem-shared-symbolic"); + let ui_sh = ui.clone(); + let entry_sh = entry.clone(); + let pop = popover.clone(); + share.connect_clicked(move |_| { + pop.popdown(); + open_share_dialog(&ui_sh, &entry_sh); + }); + menu.append(&share); + + 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(); +} + +/// A left-aligned, flat icon+label button for the context menu. +pub(crate) fn menu_item(label: &str, icon: &str) -> gtk4::Button { + let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + row.append(>k4::Image::from_icon_name(icon)); + row.append( + >k4::Label::builder() + .label(label) + .halign(gtk4::Align::Start) + .hexpand(true) + .build(), + ); + let button = gtk4::Button::builder().child(&row).build(); + button.add_css_class("flat"); + button +} + +/// Fetch the [`DirEntry`] backing the model item at `pos`, if any. +pub(crate) fn entry_at(model: Option<&impl IsA>, pos: u32) -> Option { + let obj = model?.item(pos).and_downcast::()?; + let entry = obj.borrow::().clone(); + Some(entry) +} + +/// Whether an entry's name marks it as a video, reusing the same classifier the +/// Photos page splits on — so the file browser and the gallery agree on what a +/// video is. +pub(crate) fn is_video_entry(entry: &DirEntry) -> bool { + !entry.is_dir && PhotoKind::classify(Some(&entry.name), None) == PhotoKind::Video +} + +/// Stream a video straight from the mount, no download. Drive folders *are* part +/// of the FUSE mount, so a player pointed at `/` reads the file +/// through [`Core::read_range_remote`] — 4 MB blocks fetched on demand as it +/// seeks and buffers — instead of waiting for the whole file to land like +/// [`Request::OpenFile`] does. This is the point of the feature: a 2 GB HEVC +/// `.mkv` starts playing in seconds. +pub(crate) fn stream_entry(ui: &Rc, entry: &DirEntry) { + let rel = entry_rel(ui, entry); + let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config()); + let abs = mountpoint.join(&rel); + let Some(path) = abs.to_str() else { + toast_error(ui, "Couldn't play video", "The file path isn't valid UTF-8."); + return; + }; + toast(ui, &format!("Streaming “{}”…", entry.name)); + play_external(path); +} + +/// Open an entry the Nautilus way: folders descend, videos stream from the mount, +/// other files download-and-open. +pub(crate) fn activate_entry(ui: &Rc, entry: &DirEntry) { + let rel = entry_rel(ui, entry); + if entry.is_dir { + // Descending into a search hit: clear the query so the folder listing + // isn't immediately re-masked by a stale search. + if !entry.path.is_empty() { + ui.browser.search.set_text(""); + } + *ui.browser.path.borrow_mut() = rel; + load_browser(ui); + } else if is_video_entry(entry) { + // A video streams rather than downloads: that is exactly the "play it, + // don't fetch the whole thing" behaviour this is for. + stream_entry(ui, entry); + } else { + download_and_open(ui, entry); + } +} + +/// Download a file's full content into the cache and hand it to the user's +/// default application. The download-and-open path behind both a plain +/// double-click and the context menu's "Open" (including for a video, when the +/// user explicitly wants a local copy rather than to stream it). +pub(crate) fn download_and_open(ui: &Rc, entry: &DirEntry) { + let rel = entry_rel(ui, entry); + // Ignore a repeat activation of a file already downloading, so an impatient + // double-click doesn't kick off a second round-trip. + if !ui.opening.borrow_mut().insert(rel.clone()) { + return; + } + ui.busy_begin(); + let rx = spawn_request( + ui.dirs.control_socket(), + Request::OpenFile { path: rel.clone() }, + ); + let ui = ui.clone(); + glib::spawn_future_local(async move { + let result = rx.recv().await; + ui.busy_end(); + ui.opening.borrow_mut().remove(&rel); + match result { + Ok(Ok(Response::FilePath { path })) => open_path(&path), + Ok(Ok(Response::Error { message, kind })) => { + toast_failure(&ui, "Couldn't open file", &message, kind) + } + _ => toast_error( + &ui, + "Couldn't open file", + "The mount service didn't respond.", + ), + } + }); +} + +/// Pin or unpin an entry through the daemon, then reload to reflect the new +/// state. +pub(crate) fn toggle_pin(ui: &Rc, entry: &DirEntry) { + let rel = entry_rel(ui, entry); + let req = if entry.pinned { + Request::Unpin { path: rel } + } else { + Request::Pin { path: rel } + }; + let rx = spawn_request(ui.dirs.control_socket(), req); + let name = entry.name.clone(); + let pinned = entry.pinned; + let ui = ui.clone(); + glib::spawn_future_local(async move { + match rx.recv().await { + Ok(Ok(Response::Error { message, kind })) => { + toast_failure(&ui, "Couldn't change offline state", &message, kind); + // The details switch may be showing the state we failed to reach. + load_browser(&ui); + } + Ok(Ok(_)) => { + load_browser(&ui); + toast( + &ui, + &if pinned { + format!("“{name}” is no longer kept offline") + } else { + format!("“{name}” is now available offline") + }, + ); + } + _ => { + toast_error( + &ui, + "Couldn't change offline state", + "The mount service didn't respond.", + ); + load_browser(&ui); + } + } + }); +} + +/// Join the entry name onto the current browser directory to get its +/// mountpoint-relative path. +pub(crate) fn entry_rel(ui: &Rc, entry: &DirEntry) -> String { + // Search hits carry an absolute (mountpoint-relative) path since they can + // live anywhere; plain listing entries derive it from the current folder. + if !entry.path.is_empty() { + return entry.path.clone(); + } + let base = ui.browser.path.borrow(); + if base.is_empty() { + entry.name.clone() + } else { + format!("{base}/{}", entry.name) + } +} + +/// 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. +pub(crate) 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). +pub(crate) 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, Upload-files and Upload-folder buttons. +pub(crate) fn wire_browser_actions( + ui: &Rc, + new_folder: >k4::Button, + upload: >k4::Button, + upload_folder: >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)); + let ui_uf = ui.clone(); + upload_folder.connect_clicked(move |_| prompt_upload_folder(&ui_uf)); +} + +/// Send a mutating request (rename / move / delete / mkdir / upload, or a trash +/// restore / purge) on a worker thread, then reload the listing it changed and +/// confirm with a toast, or report the daemon's error in one. `done` is the +/// past-tense confirmation ("Renamed to “x”"); `failed` names the attempt +/// ("Couldn't rename"). +pub(crate) fn run_mutation(ui: &Rc, req: Request, done: String, failed: &'static str) { + if !*ui.mounted.borrow() { + toast_error(ui, failed, "Proton Drive isn't connected."); + return; + } + 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 { .. })) => { + // The listing the mutation changed is stale now; reload it, then + // confirm, so the toast lands over the updated view. + reload_listing(&ui); + toast(&ui, &done); + } + Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, failed, &message, kind), + _ => toast_error(&ui, failed, "The mount service didn't respond."), + } + }); +} + +/// Reload the listing a completed mutation invalidated: whichever of the two +/// listing pages is on screen, since that is the one the action was raised from. +pub(crate) fn reload_listing(ui: &Rc) { + match ui.stack.visible_child_name().as_deref() { + Some("browser") => load_browser(ui), + Some("trash") => load_trash(ui), + _ => {} + } +} + +/// Prompt for a new name and rename the entry through the daemon. +pub(crate) 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") + .activates_default(true) + .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; + } + let done = format!("Renamed to “{new_name}”"); + run_mutation( + &ui, + Request::Rename { + path: rel.clone(), + new_name, + }, + done, + "Couldn't rename", + ); + }); + dialog.present(parent.as_ref()); +} + +/// Prompt for a destination folder (mountpoint-relative, empty = Drive root) and +/// move the entry there through the daemon. +pub(crate) fn prompt_move(ui: &Rc, entry: &DirEntry) { + let parent = ui_window(ui); + let rel = entry_rel(ui, entry); + let name = entry.name.clone(); + 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") + .activates_default(true) + .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(); + let done = match new_parent.as_str() { + "" => format!("Moved “{name}” to Proton Drive"), + dest => format!("Moved “{name}” to “{dest}”"), + }; + run_mutation( + &ui, + Request::Move { + path: rel.clone(), + new_parent, + }, + done, + "Couldn't move", + ); + }); + dialog.present(parent.as_ref()); +} + +/// Confirm and move the entry to Trash through the daemon. +pub(crate) fn prompt_delete(ui: &Rc, entry: &DirEntry) { + let win = ui_window(ui); + let rel = entry_rel(ui, entry); + let name = entry.name.clone(); + 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() }, + format!("Moved “{name}” to Trash"), + "Couldn't move to Trash", + ); + } + }); + dialog.present(win.as_ref()); +} + +/// Prompt for a folder name and create it under the current browser directory. +pub(crate) 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") + .activates_default(true) + .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; + } + let done = format!("Created “{name}”"); + run_mutation( + &ui, + Request::CreateFolder { + parent: parent.clone(), + name, + }, + done, + "Couldn't create folder", + ); + }); + dialog.present(win.as_ref()); +} + +/// Pick one or more local files and upload them into the current browser +/// directory. The daemon streams them from disk itself, so nothing is read into +/// the GUI — even a large multi-file selection. +pub(crate) fn prompt_upload(ui: &Rc) { + let win = ui_window(ui); + let dialog = gtk4::FileDialog::builder().title("Upload Files").build(); + let ui = ui.clone(); + dialog.open_multiple(win.as_ref(), gio::Cancellable::NONE, move |res| { + let Ok(files) = res else { return }; + let sources: Vec = files + .into_iter() + .filter_map(|f| f.ok()) + .filter_map(|obj| obj.downcast::().ok()) + .filter_map(|f| f.path()) + .filter_map(|p| p.to_str().map(str::to_string)) + .collect(); + start_upload(&ui, sources); + }); +} + +/// Pick a local folder and upload it — with its whole subtree — into the current +/// browser directory. The daemon recreates the directory structure remotely. +pub(crate) fn prompt_upload_folder(ui: &Rc) { + let win = ui_window(ui); + let dialog = gtk4::FileDialog::builder().title("Upload 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().and_then(|p| p.to_str().map(str::to_string)) else { + return; + }; + start_upload(&ui, vec![path]); + }); +} + +/// Hand a set of local source paths to the daemon for background bulk upload. +/// The daemon acks at once and does the work off-socket, so we confirm the +/// hand-off with a toast; the Activity group then shows live progress and the +/// listing refreshes itself when the transfers finish (see [`repaint_transfers`]). +pub(crate) fn start_upload(ui: &Rc, sources: Vec) { + if sources.is_empty() { + return; + } + if !*ui.mounted.borrow() { + toast_error(ui, "Couldn't upload", "Proton Drive isn't connected."); + return; + } + let parent = ui.browser.path.borrow().clone(); + let n = sources.len(); + let rx = spawn_request( + ui.dirs.control_socket(), + Request::UploadPaths { parent, sources }, + ); + let ui = ui.clone(); + glib::spawn_future_local(async move { + match rx.recv().await { + Ok(Ok(Response::Ok { .. })) => { + let what = if n == 1 { + "Uploading…".to_string() + } else { + format!("Uploading {n} items…") + }; + toast(&ui, &what); + } + Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, "Couldn't upload", &message, kind), + _ => toast_error(&ui, "Couldn't upload", "The mount service didn't respond."), + } + }); +} + +/// 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. +pub(crate) 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. +pub(crate) 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. +pub(crate) 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. +pub(crate) 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; + } + let done = format!("Moved into “{}”", dest.name); + run_mutation( + &ui, + Request::Move { + path: src, + new_parent: dest_path, + }, + done, + "Couldn't move", + ); + true + }); + anchor.add_controller(target); +} + +/// Pick a freedesktop icon base name for an entry from its kind / extension. +/// Callers append `-symbolic` for the column view's small icons. +pub(crate) fn icon_base_for(entry: &DirEntry) -> &'static str { + if entry.is_dir { + return "folder"; + } + let ext = entry + .name + .rsplit('.') + .next() + .unwrap_or("") + .to_ascii_lowercase(); + match ext.as_str() { + "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" | "svg" | "heic" | "tiff" => { + "image-x-generic" + } + "mp4" | "mkv" | "mov" | "avi" | "webm" | "m4v" => "video-x-generic", + "mp3" | "flac" | "wav" | "ogg" | "opus" | "m4a" => "audio-x-generic", + "pdf" | "doc" | "docx" | "odt" => "x-office-document", + "xls" | "xlsx" | "ods" | "csv" => "x-office-spreadsheet", + "ppt" | "pptx" | "odp" => "x-office-presentation", + "zip" | "tar" | "gz" | "xz" | "bz2" | "7z" | "rar" => "package-x-generic", + _ => "text-x-generic", + } +} + +/// Format an epoch-seconds modification time as a short local date. +pub(crate) fn format_modified(secs: i64) -> String { + match glib::DateTime::from_unix_local(secs) { + Ok(dt) => dt + .format("%-d %b %Y") + .map(|s| s.to_string()) + .unwrap_or_default(), + Err(_) => String::new(), + } +} + +/// Request the current browser directory from the daemon and repaint both views. +pub(crate) fn load_browser(ui: &Rc) { + let path = ui.browser.path.borrow().clone(); + 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 + // entries visible, where clicking one would open with a wrong relative path. + ui.browser.model.remove_all(); + browser_status( + ui, + "folder-symbolic", + "Loading…", + "Reading this folder.", + false, + ); + + ui.busy_begin(); + let rx = spawn_request( + ui.dirs.control_socket(), + Request::ListDir { path: path.clone() }, + ); + let ui = ui.clone(); + glib::spawn_future_local(async move { + let result = rx.recv().await; + ui.busy_end(); + // The user may have navigated on while this folder was loading. A stale + // out-of-order reply must not repaint rows for a folder we've left, or + // the breadcrumb and the grid would disagree. + if *ui.browser.path.borrow() != path { + return; + } + match result { + Ok(Ok(Response::Entries { entries })) => repaint_browser(&ui, &entries), + Ok(Ok(Response::Error { message, kind })) => browser_failed(&ui, &message, kind), + Ok(Ok(_)) => browser_failed(&ui, "Unexpected reply from the mount service.", ErrorKind::Internal), + Ok(Err(_)) | Err(_) => browser_unreachable(&ui), + } + }); +} + +/// Clear the model and show the daemon's error on the status page. Used for +/// in-band failures (a bad path, a permission error) — the mount is up, so Retry +/// (which restarts the service) wouldn't help and isn't offered. +pub(crate) fn browser_failed(ui: &Rc, message: &str, kind: ErrorKind) { + ui.browser.model.remove_all(); + browser_status( + ui, + "dialog-warning-symbolic", + error_headline(kind, "Couldn't open this folder"), + message, + // Offer Retry only where repeating the request could actually work. + // A folder that is gone stays gone however many times it is asked for. + kind.retryable(), + ); +} + +/// The daemon didn't answer. Distinguish *still starting* (auto-retry, no +/// button) from *down* (actionable error + Retry), so a cold start self-heals +/// once the systemd mount comes up but a real failure stays visible. +pub(crate) fn browser_unreachable(ui: &Rc) { + if service::is_failed() || !service::is_active() { + browser_status( + ui, + "network-offline-symbolic", + "Not connected", + "The Proton Drive mount service isn't running.", + true, + ); + return; + } + browser_status( + ui, + "folder-remote-symbolic", + "Connecting…", + "Waiting for the Proton Drive mount service to come up.", + false, + ); + let ui = ui.clone(); + glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || { + // Only keep polling while the Files page is the one on screen. + if ui.stack.visible_child_name().as_deref() == Some("browser") { + load_browser(&ui); + } + }); +} + +/// Repopulate the shared model — folders first, then case-insensitive by name — +/// which refreshes both the grid and the column list. +pub(crate) fn repaint_browser(ui: &Rc, entries: &[DirEntry]) { + ui.browser.model.remove_all(); + if entries.is_empty() { + browser_status( + ui, + "folder-open-symbolic", + "This folder is empty", + "Upload a file or create a folder to get started.", + false, + ); + return; + } + browser_views(ui); + + let mut sorted = entries.to_vec(); + sorted.sort_by(|a, b| { + b.is_dir + .cmp(&a.is_dir) + .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) + }); + for entry in sorted { + ui.browser.model.append(&BoxedAnyObject::new(entry)); + } +} + +/// Wire the browser header's search box: debounce keystrokes, then either run a +/// search or — when cleared — restore the current directory listing. +pub(crate) fn wire_search(ui: &Rc) { + let ui_s = ui.clone(); + ui.browser.search.connect_search_changed(move |_| { + // Replace any pending debounce so only the last keystroke's pause fires. + if let Some(src) = ui_s.browser.search_source.borrow_mut().take() { + src.remove(); + } + let ui_t = ui_s.clone(); + let src = glib::timeout_add_local_once(SEARCH_DEBOUNCE, move || { + ui_t.browser.search_source.borrow_mut().take(); + let query = ui_t.browser.search.text().trim().to_string(); + if query.is_empty() { + load_browser(&ui_t); + } else { + run_search(&ui_t, &query); + } + }); + *ui_s.browser.search_source.borrow_mut() = Some(src); + }); +} + +/// Send a [`Request::Search`] to the daemon and render the hits in the browser +/// views, reusing the same row model so click-to-open and pin work unchanged +/// (each hit carries its full path; see [`entry_rel`]). +pub(crate) fn run_search(ui: &Rc, query: &str) { + ui.browser.model.remove_all(); + browser_status( + ui, + "system-search-symbolic", + "Searching…", + &format!("Looking for “{query}”."), + false, + ); + + ui.busy_begin(); + let query = query.to_string(); + let rx = spawn_request( + ui.dirs.control_socket(), + Request::Search { + query: query.clone(), + limit: SEARCH_LIMIT, + }, + ); + let ui = ui.clone(); + glib::spawn_future_local(async move { + let result = rx.recv().await; + ui.busy_end(); + // The box may have been cleared or typed past while the reply was in + // flight; if the query no longer matches, a fresher load/search already + // owns the model — drop this stale, possibly out-of-order result. + if ui.browser.search.text().trim() != query { + return; + } + match result { + Ok(Ok(Response::SearchResults { hits })) => repaint_search(&ui, &hits), + Ok(Ok(Response::Error { message, kind })) => browser_failed(&ui, &message, kind), + Ok(Ok(_)) => browser_failed(&ui, "Unexpected reply from the mount service.", ErrorKind::Internal), + Ok(Err(_)) | Err(_) => browser_unreachable(&ui), + } + }); +} + +/// Repopulate the model with search hits — folders first, then by name — mapping +/// each [`SearchHit`] to a path-carrying [`DirEntry`] the existing renderers and +/// handlers already understand. +pub(crate) fn repaint_search(ui: &Rc, hits: &[SearchHit]) { + ui.browser.model.remove_all(); + if hits.is_empty() { + browser_status( + ui, + "system-search-symbolic", + "No matches", + "No files or folders match that search.", + false, + ); + return; + } + browser_views(ui); + + let mut entries: Vec = hits + .iter() + .map(|h| DirEntry { + name: h.name.clone(), + is_dir: h.is_dir, + 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(), + }) + .collect(); + entries.sort_by(|a, b| { + b.is_dir + .cmp(&a.is_dir) + .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) + }); + for entry in entries { + ui.browser.model.append(&BoxedAnyObject::new(entry)); + } +} diff --git a/crates/pdfs-gui/src/app/pages/devices.rs b/crates/pdfs-gui/src/app/pages/devices.rs new file mode 100644 index 0000000..3022f3c --- /dev/null +++ b/crates/pdfs-gui/src/app/pages/devices.rs @@ -0,0 +1,719 @@ +use crate::*; + +pub(crate) struct DevicesState { + // Devices page: two sections (synced folders + other devices), rebuilt + // wholesale on each load. + pub(crate) content: gtk4::Stack, + pub(crate) status: adw::StatusPage, + pub(crate) retry: gtk4::Button, + pub(crate) group: adw::PreferencesGroup, + pub(crate) rows: RefCell>, + pub(crate) sync_group: adw::PreferencesGroup, + pub(crate) sync_rows: RefCell>, + pub(crate) inflight: Cell, + pub(crate) loaded_at: Cell>, +} + +/// Widgets the Devices page's load/repaint touch. +pub(crate) struct DevicesWidgets { + pub(crate) content: gtk4::Stack, + pub(crate) status: adw::StatusPage, + /// "This computer" — the local folders synced to this machine's device. + pub(crate) sync_group: adw::PreferencesGroup, + /// "Other computers" — the account's *other* registered devices. This + /// machine's own device is deliberately not among them; see + /// [`repaint_devices`]. + pub(crate) group: adw::PreferencesGroup, + pub(crate) add_folder: gtk4::Button, + pub(crate) retry: gtk4::Button, + pub(crate) refresh: gtk4::Button, +} + +/// The Computers page: a "This computer" section listing the local folders synced +/// to this machine's device (each row offering a mode toggle and Remove), plus an +/// "Other devices" section listing the account's other registered devices. +pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) { + let title = gtk4::Label::builder() + .label("Computers") + .halign(gtk4::Align::Start) + .build(); + title.add_css_class("title-2"); + let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 2); + titles.set_hexpand(true); + titles.append(&title); + + let add_folder = gtk4::Button::builder() + .label("Add Folder") + .valign(gtk4::Align::Center) + .build(); + add_folder.add_css_class("flat"); + let refresh = refresh_button(); + + let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + header.append(&titles); + header.append(&refresh); + header.append(&add_folder); + + // The description spells out what the per-row On-demand switch does to the + // local copy, because the switch itself can't: turning it on *deletes* the + // files from this disk, which is not something to discover afterwards. + let sync_group = adw::PreferencesGroup::builder() + .title("This computer") + .description( + "Local folders backed up to this device. Synced folders keep a full copy on \ + this disk; on-demand folders keep the files in Proton Drive only and fetch \ + them as you open them.", + ) + .build(); + let group = adw::PreferencesGroup::builder() + .title("Other computers") + .description("Other devices backing up to this account.") + .build(); + + let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18); + groups.append(&sync_group); + groups.append(&group); + let clamp = adw::Clamp::builder().child(&groups).build(); + let scroll = gtk4::ScrolledWindow::builder() + .vexpand(true) + .child(&clamp) + .build(); + + let retry = gtk4::Button::builder() + .label("Retry") + .halign(gtk4::Align::Center) + .build(); + retry.add_css_class("pill"); + retry.add_css_class("suggested-action"); + retry.set_visible(false); + let status = adw::StatusPage::builder() + .icon_name("computer-symbolic") + .vexpand(true) + .child(&retry) + .build(); + status.add_css_class("compact"); + + let content = gtk4::Stack::new(); + content.set_vexpand(true); + content.set_transition_type(gtk4::StackTransitionType::Crossfade); + content.add_named(&scroll, Some("list")); + content.add_named(&status, Some("status")); + + let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12); + inner.set_margin_top(12); + inner.set_margin_bottom(12); + inner.set_margin_start(12); + inner.set_margin_end(12); + inner.append(&header); + inner.append(&content); + + ( + inner.upcast(), + DevicesWidgets { + content, + status, + sync_group, + group, + add_folder, + retry, + refresh, + }, + ) +} + +/// Install the Devices page's retry button and the "Add Folder" action. +pub(crate) fn wire_devices(ui: &Rc, retry: >k4::Button, add_folder: >k4::Button) { + let ui_retry = ui.clone(); + retry.connect_clicked(move |_| { + service::restart(); + load_devices(&ui_retry); + }); + let ui_add = ui.clone(); + add_folder.connect_clicked(move |_| prompt_add_sync_folder(&ui_add)); +} + +/// Show a status page in place of the Devices list. +pub(crate) fn devices_status(ui: &Rc, icon: &str, title: &str, description: &str, retry: bool) { + ui.devices.status.set_icon_name(Some(icon)); + ui.devices.status.set_title(title); + ui.devices.status.set_description(Some(description)); + ui.devices.retry.set_visible(retry); + ui.devices.content.set_visible_child_name("status"); +} + +/// Fetch this machine's synced folders and the account's other devices, then +/// repaint both sections. The two requests are chained so a single unreachable +/// daemon collapses the whole page to a status view. +pub(crate) fn load_devices(ui: &Rc) { + if ui.devices.inflight.get() { + return; + } + ui.devices.inflight.set(true); + devices_status( + ui, + "computer-symbolic", + "Loading…", + "Reading your devices.", + false, + ); + ui.busy_begin(); + // The two lists are independent and the daemon serves requests concurrently, + // so fire both up front and collect them, rather than paying two round trips + // back to back. + let rx = spawn_request(ui.dirs.control_socket(), Request::ListSyncFolders); + let rx2 = spawn_request(ui.dirs.control_socket(), Request::ListDevices); + let ui = ui.clone(); + glib::spawn_future_local(async move { + let sync = rx.recv().await; + let devices_reply = rx2.recv().await; + let folders = match sync { + Ok(Ok(Response::SyncFolders { items })) => items, + // The daemon answered but not as expected — treat as empty and carry + // on to the devices list rather than failing the whole page. + Ok(Ok(_)) => Vec::new(), + Ok(Err(_)) | Err(_) => { + ui.busy_end(); + ui.devices.inflight.set(false); + ui.devices.loaded_at.set(None); + devices_unreachable(&ui); + return; + } + }; + // Daemon reachable: show the list and paint the synced-folders section. + ui.devices.content.set_visible_child_name("list"); + repaint_sync_folders(&ui, &folders); + + let devices = match devices_reply { + Ok(Ok(Response::Devices { items })) => items, + _ => Vec::new(), + }; + ui.busy_end(); + ui.devices.inflight.set(false); + repaint_devices(&ui, &devices); + ui.devices.loaded_at.set(Some(Instant::now())); + }); +} + +/// Refresh just the synced-folders section, with no status flash, no spinner and +/// no devices round-trip. Driven by the periodic tick while the Devices page is +/// on screen: a folder's sync progress is only live if something re-reads it. +/// +/// A hiccup leaves the page as it is — the next tick tries again, and a real +/// outage is reported by [`load_devices`] on the next visit. +pub(crate) fn refresh_sync_folders(ui: &Rc) { + if ui.devices.inflight.get() { + return; + } + ui.devices.inflight.set(true); + let rx = spawn_request(ui.dirs.control_socket(), Request::ListSyncFolders); + let ui = ui.clone(); + glib::spawn_future_local(async move { + let result = rx.recv().await; + ui.devices.inflight.set(false); + let Ok(Ok(Response::SyncFolders { items })) = result else { + return; + }; + // The page may have been navigated away from, or collapsed to a status + // view, while the request was in flight. + if ui.stack.visible_child_name().as_deref() == Some("devices") + && ui.devices.content.visible_child_name().as_deref() == Some("list") + { + repaint_sync_folders(&ui, &items); + } + }); +} + +/// The daemon didn't answer the Devices page. +pub(crate) fn devices_unreachable(ui: &Rc) { + if service::is_failed() || !service::is_active() { + devices_status( + ui, + "network-offline-symbolic", + "Not connected", + "The Proton Drive mount service isn't running.", + true, + ); + return; + } + devices_status( + ui, + "folder-remote-symbolic", + "Connecting…", + "Waiting for the Proton Drive mount service to come up.", + false, + ); + let ui = ui.clone(); + glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || { + if ui.stack.visible_child_name().as_deref() == Some("devices") { + load_devices(&ui); + } + }); +} + +/// Rebuild the "Other computers" section from a fresh listing. Empty is a normal +/// state (this machine may be the only device), so it shows a placeholder row +/// rather than collapsing the page. +/// +/// This machine's own device is filtered out. Deleting a device deletes its root +/// folder — every file backed up from it — and for *this* machine that would also +/// pull the ground out from under the synced folders listed directly above, so it +/// is not offered as a peer of "remove some other laptop". Removing this +/// computer's backup means removing its folders, each of which asks about its +/// cloud copy on its own terms. +pub(crate) fn repaint_devices(ui: &Rc, devices: &[DeviceInfo]) { + for row in ui.devices.rows.borrow_mut().drain(..) { + ui.devices.group.remove(&row); + } + let others: Vec<&DeviceInfo> = devices.iter().filter(|d| !d.this_device).collect(); + if others.is_empty() { + let row = adw::ActionRow::builder() + .title("No other computers") + .subtitle("Desktop apps syncing to this account appear here.") + .build(); + row.add_prefix(>k4::Image::from_icon_name("computer-symbolic")); + ui.devices.group.add(&row); + *ui.devices.rows.borrow_mut() = vec![row.upcast()]; + return; + } + let mut rows: Vec = Vec::new(); + for dev in others { + let row = adw::ActionRow::builder() + .title(&dev.name) + .subtitle(device_subtitle(dev)) + .build(); + row.add_prefix(>k4::Image::from_icon_name("computer-symbolic")); + let remove = gtk4::Button::builder() + .icon_name("user-trash-symbolic") + .tooltip_text("Remove this computer and everything it backed up") + .valign(gtk4::Align::Center) + .build(); + remove.add_css_class("flat"); + let rename = gtk4::Button::builder() + .icon_name("document-edit-symbolic") + .tooltip_text("Rename computer") + .valign(gtk4::Align::Center) + .build(); + rename.add_css_class("flat"); + let ui_ren = ui.clone(); + let uid_ren = dev.uid.clone(); + let name_ren = dev.name.clone(); + rename.connect_clicked(move |_| prompt_rename_device(&ui_ren, &uid_ren, &name_ren)); + let ui_rm = ui.clone(); + let uid_rm = dev.uid.clone(); + let name_rm = dev.name.clone(); + remove.connect_clicked(move |_| prompt_remove_device(&ui_rm, &uid_rm, &name_rm)); + row.add_suffix(&rename); + row.add_suffix(&remove); + ui.devices.group.add(&row); + rows.push(row.upcast()); + } + *ui.devices.rows.borrow_mut() = rows; +} + +/// A device row's subtitle: its platform, and when it last synced. A device that +/// has never synced says so — an unexplained missing date reads as a bug, and +/// "never" is the fact that tells the user this computer isn't backing anything up. +pub(crate) fn device_subtitle(dev: &DeviceInfo) -> String { + match dev.last_sync { + Some(secs) if secs > 0 => { + format!("{} · last synced {}", dev.device_type, activity_time(secs)) + } + _ => format!("{} · never synced", dev.device_type), + } +} + +/// Rebuild the "This computer" section from a fresh synced-folders listing. +pub(crate) fn repaint_sync_folders(ui: &Rc, folders: &[SyncFolderInfo]) { + for row in ui.devices.sync_rows.borrow_mut().drain(..) { + ui.devices.sync_group.remove(&row); + } + if folders.is_empty() { + let row = adw::ActionRow::builder() + .title("No synced folders") + .subtitle("Add a folder to back it up to this device.") + .build(); + row.add_prefix(>k4::Image::from_icon_name("folder-symbolic")); + ui.devices.sync_group.add(&row); + *ui.devices.sync_rows.borrow_mut() = vec![row.upcast()]; + return; + } + let mut rows: Vec = Vec::new(); + for f in folders { + // A running pass describes itself ("Uploading photo.jpg — 12 of 40"); + // otherwise fall back to the folder's resting state. + let status = match &f.progress { + Some(p) => sync_progress_label(p), + None => sync_state_label(&f.state).to_string(), + }; + // A queued switch is where the folder is *going*, which is what the user + // just asked for and is waiting to see happen — so it leads the subtitle + // instead of the mode the folder is still technically in. + let subtitle = match (f.pending_mode.as_deref(), f.mode.as_str()) { + (Some("ondemand"), _) => format!("Going on-demand · {status}"), + (Some(_), _) => format!("Switching to synced · {status}"), + (None, "ondemand") => format!("On-demand · {status}"), + (None, _) => format!("Synced · {status}"), + }; + let row = adw::ActionRow::builder() + .title(&f.local_path) + .subtitle(&subtitle) + .build(); + row.add_prefix(>k4::Image::from_icon_name("folder-symbolic")); + let id = f.id; + + // A pass that knows how far it has got shows it: the subtitle alone + // ("syncing photo.jpg — 12 of 40") never conveys that the end is near. This + // holds for scanning as much as applying — a big folder spends minutes there + // — but a folder syncing for the first time has no estimate to draw against + // (`total == 0`), and this row is repainted on the 2s tick, too slow for a + // pulse to read as motion. So it stays text-only until real counts exist. + if let Some(p) = &f.progress + && p.total > 0 + { + let bar = gtk4::ProgressBar::builder() + .fraction((p.done as f64 / p.total.max(p.done) as f64).min(1.0)) + .valign(gtk4::Align::Center) + .width_request(120) + .build(); + row.add_suffix(&bar); + } + + // Sync now — only meaningful for mirror folders (on-demand has no local + // copy to reconcile). The engine also syncs on file changes and on a + // 120s poll; this is the "don't wait" button. + if f.mode != "ondemand" { + let sync_now = gtk4::Button::builder() + .icon_name("view-refresh-symbolic") + .tooltip_text("Sync this folder now") + .valign(gtk4::Align::Center) + .build(); + sync_now.add_css_class("flat"); + let ui_sync = ui.clone(); + sync_now.connect_clicked(move |_| { + let rx = spawn_request( + ui_sync.dirs.control_socket(), + Request::SyncNow { id: Some(id) }, + ); + let ui_sync = ui_sync.clone(); + glib::spawn_future_local(async move { + match rx.recv().await { + Ok(Ok(Response::Ok { .. })) => toast(&ui_sync, "Syncing folder…"), + Ok(Ok(Response::Error { message, kind })) => { + toast_failure(&ui_sync, "Couldn't sync", &message, kind) + } + _ => toast_error( + &ui_sync, + "Couldn't sync", + "The mount service didn't respond.", + ), + } + }); + }); + row.add_suffix(&sync_now); + } + + // On-demand toggle: off = full local copy (mirror), on = FUSE mount that + // frees the disk. Set the state before wiring the handler so painting the + // current mode doesn't fire a spurious request. + // + // A queued switch paints as already flipped: the daemon accepted the + // request and will act on it, so snapping the switch back to the current + // mode would read as "it didn't take" and invite the user to toggle again. + let target = f.pending_mode.as_deref().unwrap_or(&f.mode); + let ondemand = gtk4::Switch::builder() + .tooltip_text( + "On-demand: free this disk by keeping the files in Proton Drive only, \ + fetching each as you open it. Turn off to download them back and keep a \ + full local copy.", + ) + .valign(gtk4::Align::Center) + .active(target == "ondemand") + .build(); + let ui_mode = ui.clone(); + ondemand.connect_state_set(move |_, on| { + set_sync_folder_mode(&ui_mode, id, if on { "ondemand" } else { "mirror" }); + glib::Propagation::Proceed + }); + row.add_suffix(&ondemand); + + let remove = gtk4::Button::builder() + .icon_name("user-trash-symbolic") + .tooltip_text("Stop syncing this folder") + .valign(gtk4::Align::Center) + .build(); + remove.add_css_class("flat"); + let ui_rm = ui.clone(); + let path = f.local_path.clone(); + // The folder's *current* mode, not a queued one: a switch that hasn't + // landed yet has not moved the files anywhere. + let ondemand = f.mode == "ondemand"; + remove.connect_clicked(move |_| prompt_remove_sync_folder(&ui_rm, id, &path, ondemand)); + row.add_suffix(&remove); + ui.devices.sync_group.add(&row); + rows.push(row.upcast()); + } + *ui.devices.sync_rows.borrow_mut() = rows; +} + +/// Human label for a synced folder's `state` column. +pub(crate) fn sync_state_label(state: &str) -> &str { + match state { + "syncing" => "syncing…", + "error" => "sync error", + "conflict" => "needs attention", + _ => "up to date", + } +} + +/// Human label for a sync pass in flight: what it is doing, to which file, and +/// how far along it is. Neither phase's total is exact — the scan's is an estimate +/// from the last pass, and the applying total grows as deeper paths are classified +/// — so both read "12 of 40" rather than a percentage that could go backwards. +pub(crate) fn sync_progress_label(p: &SyncProgress) -> String { + match p.phase { + // Before the first pass finishes there is no estimate, so the count would + // be "checked 12 of 12" — worse than saying nothing. + SyncPhase::Scanning if p.total == 0 => "checking for changes…".to_string(), + SyncPhase::Scanning => format!( + "checking for changes — {} of {}", + p.done, + p.total.max(p.done) + ), + SyncPhase::Applying => { + let count = format!("{} of {}", p.done + 1, p.total.max(p.done + 1)); + if p.current.is_empty() { + format!("syncing {count}") + } else { + format!("syncing {} — {count}", p.current) + } + } + } +} + +/// Pick a local folder and hand it to the daemon to sync to this device. +pub(crate) fn prompt_add_sync_folder(ui: &Rc) { + let win = ui_window(ui); + let dialog = gtk4::FileDialog::builder() + .title("Add Folder to Sync") + .build(); + let ui = ui.clone(); + dialog.select_folder(win.as_ref(), gio::Cancellable::NONE, move |res| { + let Ok(folder) = res else { return }; + let Some(local_path) = folder.path().and_then(|p| p.to_str().map(str::to_string)) else { + return; + }; + let rx = spawn_request( + ui.dirs.control_socket(), + Request::AddSyncFolder { + local_path: local_path.clone(), + }, + ); + let ui = ui.clone(); + glib::spawn_future_local(async move { + match rx.recv().await { + Ok(Ok(Response::Ok { .. })) => { + // The daemon acks before the row exists — registering the + // device and creating the remote folder is off-socket network + // work. A fixed delay here either fires too early (empty list) + // or too late (row flashes in). Instead let the periodic + // `refresh_sync_folders` tick pick the row up whenever it + // actually lands, which is what keeps a running pass live too. + toast(&ui, "Syncing folder…"); + } + Ok(Ok(Response::Error { message, kind })) => { + toast_failure(&ui, "Couldn't add folder", &message, kind) + } + _ => toast_error( + &ui, + "Couldn't add folder", + "The mount service didn't respond.", + ), + } + }); + }); +} + +/// Flip a synced folder between `mirror` and `ondemand`. Reloads after so the +/// row's subtitle and switch reflect the daemon's real state (the request may be +/// rejected, e.g. switching to on-demand while a folder is mid-sync). +pub(crate) fn set_sync_folder_mode(ui: &Rc, id: i64, mode: &str) { + let rx = spawn_request( + ui.dirs.control_socket(), + Request::SetSyncFolderMode { + id, + mode: mode.to_string(), + }, + ); + let ui = ui.clone(); + glib::spawn_future_local(async move { + match rx.recv().await { + Ok(Ok(Response::Ok { message })) => toast(&ui, &message), + Ok(Ok(Response::Error { message, kind })) => { + toast_failure(&ui, "Couldn't change mode", &message, kind) + } + _ => toast_error( + &ui, + "Couldn't change mode", + "The mount service didn't respond.", + ), + } + if ui.stack.visible_child_name().as_deref() == Some("devices") { + load_devices(&ui); + } + }); +} + +/// Confirm, then stop syncing a folder. Offers to also delete the cloud copy. +pub(crate) fn prompt_remove_sync_folder(ui: &Rc, id: i64, path: &str, ondemand: bool) { + let win = ui_window(ui); + // The two modes leave the user in opposite places, so they can't share a + // sentence. A mirror folder's files are already on this disk and stay there. + // An on-demand folder's are not: the path is a mount over content that lives + // in Proton Drive, so unmounting it leaves an empty directory — which is a + // nasty surprise if the dialog claimed the local files were safe, and is + // recoverable only by turning On-demand off *first* and letting it download. + let body = if ondemand { + format!( + "Stop syncing “{path}”?\n\nThis folder is on-demand: its files live in Proton \ + Drive, not on this disk, so the folder will be empty once it is unmounted. To \ + keep a local copy, cancel, turn off On-demand, and wait for the download to \ + finish before removing it." + ) + } else { + format!( + "Stop syncing “{path}”?\n\nThe local files stay on this disk and simply stop \ + being synced. Choose whether to also delete the copy in Proton Drive." + ) + }; + let dialog = adw::AlertDialog::builder() + .heading("Stop syncing folder") + .body(body) + .build(); + let group = adw::PreferencesGroup::new(); + let delete_remote = adw::SwitchRow::builder() + .title("Also delete from Proton Drive") + .subtitle(if ondemand { + "Deletes the only copy of these files." + } else { + "The local copy is unaffected." + }) + .active(false) + .build(); + group.add(&delete_remote); + dialog.set_extra_child(Some(&group)); + dialog.add_response("cancel", "Cancel"); + dialog.add_response("remove", "Stop Syncing"); + dialog.set_response_appearance("remove", 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 == "remove" { + run_devices_mutation( + &ui, + Request::RemoveSyncFolder { + id, + delete_remote: delete_remote.is_active(), + }, + "Stopped syncing folder", + "Couldn't stop syncing the folder", + ); + } + }); + dialog.present(win.as_ref()); +} + +/// Prompt for a new device name and rename it. +pub(crate) fn prompt_rename_device(ui: &Rc, uid: &str, current: &str) { + let win = ui_window(ui); + let dialog = adw::AlertDialog::builder() + .heading("Rename device") + .body(format!("Rename “{current}”.")) + .build(); + let group = adw::PreferencesGroup::new(); + let row = adw::EntryRow::builder() + .title("New name") + .activates_default(true) + .build(); + row.set_text(current); + group.add(&row); + dialog.set_extra_child(Some(&group)); + dialog.add_response("cancel", "Cancel"); + dialog.add_response("rename", "Rename"); + dialog.set_response_appearance("rename", adw::ResponseAppearance::Suggested); + dialog.set_default_response(Some("rename")); + dialog.set_close_response("cancel"); + let ui = ui.clone(); + let uid = uid.to_string(); + dialog.connect_response(None, move |_, resp| { + if resp != "rename" { + return; + } + let name = row.text().trim().to_string(); + if name.is_empty() { + toast_error(&ui, "Couldn't rename device", "A name is required."); + return; + } + run_devices_mutation( + &ui, + Request::RenameDevice { + uid: uid.clone(), + name, + }, + "Device renamed", + "Couldn't rename the device", + ); + }); + dialog.present(win.as_ref()); +} + +/// Confirm, then remove (deregister) a device. +pub(crate) fn prompt_remove_device(ui: &Rc, uid: &str, name: &str) { + let win = ui_window(ui); + let dialog = adw::AlertDialog::builder() + .heading("Remove computer") + .body(format!( + "Remove “{name}” from this account?\n\nEverything it backed up to Proton Drive is \ + deleted along with it. The files on that computer itself are not touched — but \ + this cannot be undone from here." + )) + .build(); + dialog.add_response("cancel", "Cancel"); + dialog.add_response("remove", "Remove"); + dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive); + dialog.set_default_response(Some("cancel")); + dialog.set_close_response("cancel"); + let ui = ui.clone(); + let uid = uid.to_string(); + dialog.connect_response(None, move |_, resp| { + if resp == "remove" { + run_devices_mutation( + &ui, + Request::DeleteDevice { uid: uid.clone() }, + "Device removed", + "Couldn't remove the device", + ); + } + }); + dialog.present(win.as_ref()); +} + +/// Run a mutation raised from the Devices page and reload the page on success. +pub(crate) fn run_devices_mutation(ui: &Rc, req: Request, done: &'static str, failed: &'static str) { + ui.busy_begin(); + let rx = spawn_request(ui.dirs.control_socket(), req); + let ui = ui.clone(); + let done = done.to_string(); + glib::spawn_future_local(async move { + let result = rx.recv().await; + ui.busy_end(); + match result { + Ok(Ok(Response::Ok { .. })) => { + load_devices(&ui); + toast(&ui, &done); + } + Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, failed, &message, kind), + _ => toast_error(&ui, failed, "The mount service didn't respond."), + } + }); +} diff --git a/crates/pdfs-gui/src/app/pages/login.rs b/crates/pdfs-gui/src/app/pages/login.rs new file mode 100644 index 0000000..0452a6f --- /dev/null +++ b/crates/pdfs-gui/src/app/pages/login.rs @@ -0,0 +1,237 @@ +use crate::*; + +pub(crate) struct LoginState { + // Login page. + pub(crate) email: adw::EntryRow, + pub(crate) password: adw::PasswordEntryRow, + pub(crate) login_button: gtk4::Button, + pub(crate) login_status: gtk4::Label, +} + +/// The login page: an email row, password row, a primary "Sign in" button and a +/// status label, centred in a clamp. The 2FA code is prompted lazily in a +/// dialog (see [`prompt_2fa`]) only when the account actually requires it. +#[allow(clippy::type_complexity)] +pub(crate) fn build_login_page() -> ( + gtk4::Widget, + ( + adw::EntryRow, + adw::PasswordEntryRow, + gtk4::Button, + gtk4::Label, + ), +) { + let group = adw::PreferencesGroup::builder() + .title("Sign in to Proton") + .description("Use your Proton account to connect Drive.") + .build(); + + let email = adw::EntryRow::builder().title("Email or username").build(); + let password = adw::PasswordEntryRow::builder().title("Password").build(); + group.add(&email); + group.add(&password); + + let login_button = gtk4::Button::builder() + .label("Sign in") + .halign(gtk4::Align::Center) + .build(); + login_button.add_css_class("suggested-action"); + login_button.add_css_class("pill"); + + let login_status = gtk4::Label::builder() + .wrap(true) + .justify(gtk4::Justification::Center) + .build(); + login_status.add_css_class("dim-label"); + + let header = gtk4::Box::new(gtk4::Orientation::Vertical, 4); + let logo = gtk4::Image::from_icon_name("folder-remote-symbolic"); + logo.set_pixel_size(64); + logo.add_css_class("brand-icon"); + let title = gtk4::Label::new(Some("Proton Drive")); + title.add_css_class("brand-title"); + header.append(&logo); + header.append(&title); + header.set_margin_bottom(12); + + let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 16); + inner.set_margin_top(32); + inner.set_margin_bottom(32); + inner.set_margin_start(12); + inner.set_margin_end(12); + inner.append(&header); + inner.append(&group); + inner.append(&login_button); + inner.append(&login_status); + + let clamp = adw::Clamp::builder() + .maximum_size(420) + .child(&inner) + .build(); + let scroll = gtk4::ScrolledWindow::builder().child(&clamp).build(); + + ( + scroll.upcast(), + (email, password, login_button, login_status), + ) +} + +/// Connect the sign-in button: read the fields, run [`auth::login`] on a worker +/// thread, and report the outcome back on the main loop. +pub(crate) fn wire_login(ui: &Rc) { + // Pressing Enter in either field submits the form, so signing in never needs + // a reach for the mouse. Both fields route to the same button. + let btn = ui.login.login_button.clone(); + ui.login.email + .connect_entry_activated(move |_| btn.emit_clicked()); + let btn = ui.login.login_button.clone(); + ui.login.password + .connect_entry_activated(move |_| btn.emit_clicked()); + + let ui = ui.clone(); + let button = ui.login.login_button.clone(); + button.connect_clicked(move |_| { + let username = ui.login.email.text().to_string(); + let password = ui.login.password.text().to_string(); + if username.is_empty() || password.is_empty() { + ui.login.login_status.set_text("Enter your email and password."); + return; + } + + ui.login.login_button.set_sensitive(false); + ui.login.login_status.set_text("Signing in…"); + let (rx, totp_req_rx) = spawn_login(username, password); + + // Surface the 2FA dialog only if the SDK actually asks for a code (i.e. + // the account has 2FA enabled). The worker blocks until the dialog feeds + // back a code (or is cancelled, dropping the sender). + let ui_2fa = ui.clone(); + glib::spawn_future_local(async move { + if let Ok(code_tx) = totp_req_rx.recv().await { + prompt_2fa(&ui_2fa, code_tx); + } + }); + + let ui = ui.clone(); + glib::spawn_future_local(async move { + let result = rx + .recv() + .await + .unwrap_or_else(|_| Err("login cancelled".into())); + ui.login.login_button.set_sensitive(true); + match result { + Ok(()) => { + ui.login.login_status.set_text(""); + ui.login.password.set_text(""); + // Cache the new identity so `refresh` never hits the keyring. + *ui.session.borrow_mut() = auth::load().ok(); + // Enable+start the mount service now that we have a session. + service::enable_start(); + refresh(&ui); + } + Err(e) => ui.login.login_status.set_text(&format!("Sign-in failed: {e}")), + } + }); + }); +} + +/// Run the async SRP + optional 2FA login on a dedicated current-thread Tokio +/// runtime. Returns two channels: the first yields the final login result once; +/// the second fires *only if* the account needs a 2FA code, carrying a +/// [`std::sync::mpsc::Sender`] the UI uses to feed the code back. The login +/// closure blocks the worker on that sender until the dialog answers, so the +/// code is requested lazily and can't expire before the password proof. +#[allow(clippy::type_complexity)] +pub(crate) fn spawn_login( + username: String, + password: String, +) -> ( + async_channel::Receiver>, + async_channel::Receiver>, +) { + let (tx, rx) = async_channel::bounded(1); + let (totp_req_tx, totp_req_rx) = async_channel::bounded(1); + std::thread::spawn(move || { + let rt = match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => { + let _ = tx.send_blocking(Err(e.to_string())); + return; + } + }; + let result = rt.block_on(async move { + auth::login(&username, &password, || { + // 2FA required: hand a one-shot sender to the UI and block until + // the dialog supplies the code. A dropped sender (cancelled + // dialog) surfaces as a cancelled login. + let (code_tx, code_rx) = std::sync::mpsc::channel::(); + totp_req_tx + .send_blocking(code_tx) + .map_err(|_| pdfs_core::Error::Other("two-factor prompt closed".into()))?; + code_rx + .recv() + .map_err(|_| pdfs_core::Error::Other("two-factor entry cancelled".into())) + }) + .await + .map_err(|e| e.to_string()) + }); + let _ = tx.send_blocking(result); + }); + (rx, totp_req_rx) +} + +/// Show the lazy two-factor dialog and feed the entered code back to the waiting +/// login worker via `code_tx`. Cancelling (or closing) drops the sender, which +/// the worker reads as a cancelled login. +pub(crate) fn prompt_2fa(ui: &Rc, code_tx: std::sync::mpsc::Sender) { + let dialog = adw::AlertDialog::builder() + .heading("Two-factor authentication") + .body("Enter the code from your authenticator app.") + .build(); + + let group = adw::PreferencesGroup::new(); + let entry = adw::EntryRow::builder() + .title("Authentication code") + .activates_default(true) + .build(); + group.add(&entry); + dialog.set_extra_child(Some(&group)); + + dialog.add_response("cancel", "Cancel"); + dialog.add_response("confirm", "Confirm"); + dialog.set_response_appearance("confirm", adw::ResponseAppearance::Suggested); + dialog.set_default_response(Some("confirm")); + dialog.set_close_response("cancel"); + + let code_tx = RefCell::new(Some(code_tx)); + dialog.connect_response(None, move |_, resp| { + // On cancel/close we take + drop `tx` without sending, so the worker's + // recv errors out and the login is reported as cancelled. + if let Some(tx) = code_tx.borrow_mut().take() + && resp == "confirm" + { + let _ = tx.send(entry.text().trim().to_string()); + } + }); + + let parent = ui.login.login_button.root().and_downcast::(); + dialog.present(parent.as_ref()); +} + +/// Connect the sign-out button: disable+stop the mount service (so the daemon +/// isn't left running without credentials), forget the stored session, and drop +/// back to the login page. +pub(crate) fn wire_logout(ui: &Rc, button: >k4::Button) { + let ui = ui.clone(); + button.connect_clicked(move |_| { + service::disable_stop(); + if let Err(e) = auth::logout() { + tracing::error!("logout failed: {e}"); + } + *ui.session.borrow_mut() = None; + refresh(&ui); + }); +} diff --git a/crates/pdfs-gui/src/app/pages/mod.rs b/crates/pdfs-gui/src/app/pages/mod.rs new file mode 100644 index 0000000..acce5a2 --- /dev/null +++ b/crates/pdfs-gui/src/app/pages/mod.rs @@ -0,0 +1,10 @@ +pub(crate) mod activity; +pub(crate) mod browser; +pub(crate) mod devices; +pub(crate) mod login; +pub(crate) mod photo_viewer; +pub(crate) mod photos; +pub(crate) mod shared; +pub(crate) mod shared_by_me; +pub(crate) mod status; +pub(crate) mod trash; diff --git a/crates/pdfs-gui/src/app/pages/photo_viewer.rs b/crates/pdfs-gui/src/app/pages/photo_viewer.rs new file mode 100644 index 0000000..a84062f --- /dev/null +++ b/crates/pdfs-gui/src/app/pages/photo_viewer.rs @@ -0,0 +1,694 @@ +use crate::*; + +/// The lightbox's mutable parts, shared by [`load_photo`], [`navigate_photo`] and +/// the button/key handlers so each one takes a single handle instead of a dozen +/// widget arguments. +pub(crate) struct Viewer { + pub(crate) picture: gtk4::Picture, + pub(crate) spinner: gtk4::Spinner, + pub(crate) status: gtk4::Label, + pub(crate) title: gtk4::Label, + pub(crate) counter: gtk4::Label, + pub(crate) prev: gtk4::Button, + pub(crate) next: gtk4::Button, + /// Details drawer: the toggle that reveals it, the rows it fills in, and the + /// "Show on map" button (hidden when the photo carries no GPS tags). + pub(crate) info_toggle: gtk4::ToggleButton, + pub(crate) info_revealer: gtk4::Revealer, + pub(crate) info_rows: gtk4::Box, + pub(crate) info_map: gtk4::Button, + /// Coordinates behind `info_map`, once a photo with GPS tags is shown. + pub(crate) coords: RefCell>, + /// uid of the photo currently on screen. + pub(crate) uid: RefCell, + /// On-disk path of the full-size photo, once it has been downloaded. + pub(crate) path: RefCell>, + /// True while the full-size photo is still downloading and what's on screen is + /// the upscaled thumbnail — a click-outside-to-close hit test has to size the + /// image from the *thumbnail's* ratio in that window, but more importantly a + /// late reply for a photo the user has already navigated away from must not + /// overwrite the new one. Guarded by comparing against `uid`. + pub(crate) loading: Cell, +} + +/// Camera/exposure/location facts pulled from a photo's own EXIF tags, as +/// label/value pairs for the details drawer. +pub(crate) struct ExifInfo { + /// `("Camera", "Apple iPhone 15")` and friends; empty when the file has no + /// EXIF at all, which is normal for screenshots and re-encoded images. + pub(crate) fields: Vec<(&'static str, String)>, + /// Decimal degrees, if the photo is geotagged. + pub(crate) coords: Option<(f64, f64)>, +} + +/// Show the photo behind `uid`: paint its (already cached) thumbnail immediately +/// so the lightbox never opens on a blank screen, ask the daemon for the +/// full-size file, and swap it in — plus its EXIF — when it lands. +pub(crate) fn load_photo(ui: &Rc, viewer: &Rc, uid: String) { + viewer.spinner.set_visible(true); + viewer.spinner.start(); + viewer.status.set_visible(false); + viewer.loading.set(true); + *viewer.path.borrow_mut() = None; + clear_info(viewer); + + // The thumbnail the gallery already decoded stands in for the full photo + // while it downloads: blurry for a moment beats black for a second. + match ui.gallery.photo_tex.borrow().get(&uid) { + Some(texture) => viewer.picture.set_paintable(Some(texture)), + None => viewer.picture.set_paintable(gtk4::gdk::Paintable::NONE), + } + + let rx = spawn_request( + ui.dirs.control_socket(), + Request::OpenPhoto { uid: uid.clone() }, + ); + let viewer = viewer.clone(); + glib::spawn_future_local(async move { + let result = rx.recv().await; + // The user may have moved on while this was in flight; that photo's own + // request owns the viewer now. + if *viewer.uid.borrow() != uid { + return; + } + viewer.spinner.stop(); + viewer.spinner.set_visible(false); + viewer.loading.set(false); + + let fail = |message: &str| { + viewer.status.set_label(message); + viewer.status.set_visible(true); + }; + match result { + Ok(Ok(Response::FilePath { path })) => match gtk4::gdk::Texture::from_filename(&path) { + Ok(texture) => { + viewer.picture.set_paintable(Some(&texture)); + *viewer.path.borrow_mut() = Some(path.clone()); + show_info(&viewer, &path, read_exif(&path)); + } + Err(e) => { + tracing::error!("Failed to load texture for {path}: {e}"); + fail("Couldn't render this photo."); + } + }, + Ok(Ok(Response::Error { message, .. })) => fail(&message), + Ok(Ok(_)) => fail("Unexpected reply from the mount service."), + Ok(Err(_)) | Err(_) => fail("Couldn't reach Proton Drive."), + } + }); +} + +/// Warm the cache with the photo `delta` steps away, so stepping there is +/// instant. Fire-and-forget: the daemon serves control connections concurrently, +/// so this rides alongside whatever the user does next, and a failure here is +/// simply a photo that downloads on arrival like it used to. +pub(crate) fn prefetch_photo(ui: &Rc, viewer: &Rc, delta: i32) { + let model = &ui.gallery.model; + let current = find_photo_index(model, &viewer.uid.borrow()).unwrap_or(0) as i32; + let index = current + delta; + if index < 0 || index >= model.n_items() as i32 { + return; + } + let Some(photo) = model + .item(index as u32) + .and_downcast::() + .map(|boxed| boxed.borrow::().clone()) + else { + return; + }; + // Never prefetch a video: the lightbox skips over it, and warming it would + // mean downloading a whole clip the user won't watch from here. + if photo.kind == PhotoKind::Video { + return; + } + let rx = spawn_request( + ui.dirs.control_socket(), + Request::OpenPhoto { uid: photo.uid }, + ); + glib::spawn_future_local(async move { + let _ = rx.recv().await; + }); +} + +/// Reset the details drawer while the next photo is in flight, so it never shows +/// the previous photo's camera against the new image. +pub(crate) fn clear_info(viewer: &Rc) { + *viewer.coords.borrow_mut() = None; + viewer.info_map.set_visible(false); + while let Some(row) = viewer.info_rows.first_child() { + viewer.info_rows.remove(&row); + } + let label = gtk4::Label::builder() + .label("Reading photo details…") + .halign(gtk4::Align::Start) + .build(); + label.add_css_class("dim-label"); + viewer.info_rows.append(&label); +} + +/// Fill the details drawer for the photo now on screen: its own file facts +/// (size, dimensions) plus whatever EXIF it carries. +pub(crate) fn show_info(viewer: &Rc, path: &str, info: ExifInfo) { + while let Some(row) = viewer.info_rows.first_child() { + viewer.info_rows.remove(&row); + } + + let mut fields: Vec<(&str, String)> = Vec::new(); + if let Ok(meta) = std::fs::metadata(path) { + fields.push(("Size", human_bytes(meta.len()))); + } + fields.extend(info.fields.iter().map(|(k, v)| (*k, v.clone()))); + + if fields.is_empty() { + let label = gtk4::Label::builder() + .label("This photo carries no metadata.") + .halign(gtk4::Align::Start) + .wrap(true) + .build(); + label.add_css_class("dim-label"); + viewer.info_rows.append(&label); + } else { + let group = gtk4::ListBox::new(); + group.set_selection_mode(gtk4::SelectionMode::None); + group.add_css_class("boxed-list"); + for (label, value) in fields { + let row = adw::ActionRow::builder() + .title(label) + .subtitle(value) + .subtitle_selectable(true) + .build(); + row.add_css_class("property"); + group.append(&row); + } + viewer.info_rows.append(&group); + } + + viewer.info_map.set_visible(info.coords.is_some()); + *viewer.coords.borrow_mut() = info.coords; +} + +/// Read the EXIF tags a gallery viewer cares about out of a decrypted photo on +/// disk. Anything missing is simply left out — phone screenshots and re-encoded +/// images legitimately carry no EXIF at all. +pub(crate) fn read_exif(path: &str) -> ExifInfo { + let mut fields: Vec<(&'static str, String)> = Vec::new(); + let mut coords = None; + + let reader = match std::fs::File::open(path) { + Ok(file) => { + match exif::Reader::new().read_from_container(&mut std::io::BufReader::new(file)) { + Ok(reader) => reader, + Err(e) => { + tracing::debug!("no exif in {path}: {e}"); + return ExifInfo { fields, coords }; + } + } + } + Err(e) => { + tracing::warn!("cannot open {path} for exif: {e}"); + return ExifInfo { fields, coords }; + } + }; + + let field = |tag: exif::Tag| { + reader + .get_field(tag, exif::In::PRIMARY) + .map(|f| f.display_value().with_unit(&reader).to_string()) + }; + + if let Some(size) = field(exif::Tag::PixelXDimension) + .zip(field(exif::Tag::PixelYDimension)) + .map(|(w, h)| format!("{w} × {h}")) + { + fields.push(("Dimensions", size)); + } + if let Some(taken) = field(exif::Tag::DateTimeOriginal) { + fields.push(("Taken", taken)); + } + + let camera = [exif::Tag::Make, exif::Tag::Model] + .iter() + .filter_map(|tag| field(*tag)) + .collect::>() + .join(" "); + if !camera.is_empty() { + fields.push(("Camera", camera)); + } + if let Some(lens) = field(exif::Tag::LensModel) { + fields.push(("Lens", lens)); + } + + let exposure: Vec = [ + exif::Tag::FNumber, + exif::Tag::ExposureTime, + exif::Tag::PhotographicSensitivity, + exif::Tag::FocalLength, + ] + .iter() + .filter_map(|tag| field(*tag)) + .collect(); + if !exposure.is_empty() { + fields.push(("Exposure", exposure.join(" · "))); + } + + if let Some(lat) = gps_degrees(&reader, exif::Tag::GPSLatitude, exif::Tag::GPSLatitudeRef) + && let Some(lon) = gps_degrees(&reader, exif::Tag::GPSLongitude, exif::Tag::GPSLongitudeRef) + { + fields.push(("Location", format!("{lat:.5}, {lon:.5}"))); + coords = Some((lat, lon)); + } + + ExifInfo { fields, coords } +} + +/// Convert one GPS coordinate from EXIF's degrees/minutes/seconds rationals to +/// decimal degrees, negating for the S/W hemispheres. +pub(crate) fn gps_degrees(reader: &exif::Exif, tag: exif::Tag, ref_tag: exif::Tag) -> Option { + let field = reader.get_field(tag, exif::In::PRIMARY)?; + let exif::Value::Rational(dms) = &field.value else { + return None; + }; + let [deg, min, sec] = dms.get(..3)? else { + return None; + }; + let degrees = deg.to_f64() + min.to_f64() / 60.0 + sec.to_f64() / 3600.0; + + let hemisphere = reader + .get_field(ref_tag, exif::In::PRIMARY) + .map(|f| f.display_value().to_string()) + .unwrap_or_default(); + let negative = hemisphere.starts_with('S') || hemisphere.starts_with('W'); + Some(if negative { -degrees } else { degrees }) +} + +pub(crate) fn save_photo_to_disk(window: >k4::Window, source_path: &str, original_name: &str) { + let dialog = gtk4::FileDialog::builder() + .title("Save Photo") + .initial_name(original_name) + .build(); + 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); + } + } + }); +} + +/// Step `delta` photos through the flat timeline model and load what lands there. +pub(crate) fn navigate_photo(ui: &Rc, viewer: &Rc, delta: i32) { + let model = &ui.gallery.model; + let n = model.n_items(); + if n == 0 { + return; + } + let uid_val = viewer.uid.borrow().clone(); + let current_idx = find_photo_index(model, &uid_val).unwrap_or(0); + + // Where the raw step lands (prev/next are ±1; Home/End are huge deltas that + // clamp to the ends), and which way it was travelling. + let start = (current_idx as i32 + delta).clamp(0, n as i32 - 1); + let dir = if delta < 0 { -1 } else { 1 }; + + // The lightbox renders stills only — a video plays in an external player, so + // it has no frame here. Land on the nearest renderable item, preferring the + // travel direction and falling back to scanning inward from a boundary (so + // Home/End still reach the first/last still even when videos sit at the end). + let renderable = |idx: u32| -> Option { + model + .item(idx) + .and_then(|obj| obj.downcast_ref::().map(|b| b.borrow::().clone())) + .filter(|photo| photo.kind != PhotoKind::Video) + }; + let scan = |from: i32, step: i32| -> Option<(u32, PhotoItem)> { + let mut probe = from; + while (0..n as i32).contains(&probe) { + if let Some(photo) = renderable(probe as u32) { + return Some((probe as u32, photo)); + } + probe += step; + } + None + }; + // Scan the travel direction first. Only fall back to scanning inward when the + // step landed on a boundary — that is the Home/End case (jump to first/last, + // then step inward past any videos there); a mid-list prev/next with only + // videos ahead should simply stay put rather than reverse. + let at_boundary = start == 0 || start == n as i32 - 1; + let landing = scan(start, dir).or_else(|| at_boundary.then(|| scan(start, -dir)).flatten()); + let Some((next_idx, photo)) = landing else { + return; + }; + + *viewer.uid.borrow_mut() = photo.uid.clone(); + show_photo_position(viewer, next_idx, n, photo.capture_time); + load_photo(ui, viewer, photo.uid.clone()); + // Keep walking in the same direction: the next one is likely where they're + // headed, so have it in the cache before they ask. + prefetch_photo(ui, viewer, delta.signum()); +} + +/// Set the lightbox's title (the photo's date) and its "12 of 340" counter. +pub(crate) fn show_photo_position(viewer: &Rc, index: u32, total: u32, capture_time: i64) { + viewer.prev.set_sensitive(index > 0); + viewer.next.set_sensitive(index + 1 < total); + viewer.title.set_label(&format_capture_time(capture_time)); + viewer + .counter + .set_label(&format!("{} of {total}", index + 1)); +} + +/// The in-app lightbox: the photo, edge-to-edge on a dark backdrop, with a +/// floating top bar, prev/next affordances and a details drawer. +/// +/// Closing it is deliberately hard to get wrong — Escape, `q`, Ctrl+W, the close +/// button, or a click on the backdrop beside the photo all dismiss it. +pub(crate) fn open_photo_viewer(ui: &Rc, initial_uid: String) { + let parent = ui.stack.root().and_downcast::().unwrap(); + + let window = gtk4::Window::builder() + .title("Photo") + .modal(true) + .transient_for(&parent) + .default_width(1100) + .default_height(760) + .build(); + window.add_css_class("photo-viewer-window"); + + let overlay = gtk4::Overlay::new(); + + let picture = gtk4::Picture::builder() + .content_fit(gtk4::ContentFit::Contain) + .hexpand(true) + .vexpand(true) + .build(); + overlay.set_child(Some(&picture)); + + let prev_btn = gtk4::Button::builder() + .icon_name("go-previous-symbolic") + .tooltip_text("Previous (←)") + .halign(gtk4::Align::Start) + .valign(gtk4::Align::Center) + .build(); + prev_btn.add_css_class("circular"); + prev_btn.add_css_class("flat"); + prev_btn.add_css_class("viewer-nav-btn"); + overlay.add_overlay(&prev_btn); + + let next_btn = gtk4::Button::builder() + .icon_name("go-next-symbolic") + .tooltip_text("Next (→)") + .halign(gtk4::Align::End) + .valign(gtk4::Align::Center) + .build(); + next_btn.add_css_class("circular"); + next_btn.add_css_class("flat"); + next_btn.add_css_class("viewer-nav-btn"); + overlay.add_overlay(&next_btn); + + // Top bar: the photo's date and position on the left, actions on the right, + // over a gradient so white controls stay legible on a bright photo. + let title_label = gtk4::Label::builder() + .halign(gtk4::Align::Start) + .ellipsize(gtk4::pango::EllipsizeMode::End) + .build(); + title_label.add_css_class("viewer-title"); + + let counter_label = gtk4::Label::builder().halign(gtk4::Align::Start).build(); + counter_label.add_css_class("viewer-counter"); + + let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 1); + titles.set_hexpand(true); + titles.set_valign(gtk4::Align::Center); + titles.append(&title_label); + titles.append(&counter_label); + + let action = |icon: &str, tooltip: &str| { + let button = gtk4::Button::builder() + .icon_name(icon) + .tooltip_text(tooltip) + .valign(gtk4::Align::Center) + .build(); + button.add_css_class("flat"); + button.add_css_class("viewer-action-btn"); + button + }; + + let info_toggle = gtk4::ToggleButton::builder() + .icon_name("info-outline-symbolic") + .tooltip_text("Details (i)") + .valign(gtk4::Align::Center) + .build(); + info_toggle.add_css_class("flat"); + info_toggle.add_css_class("viewer-action-btn"); + + let download_btn = action("document-save-symbolic", "Save a copy…"); + let open_ext_btn = action("document-open-symbolic", "Open with another app"); + let close_btn = action("window-close-symbolic", "Close (Esc)"); + close_btn.add_css_class("viewer-close-btn"); + + let top_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); + top_bar.add_css_class("viewer-top-bar"); + top_bar.set_valign(gtk4::Align::Start); + top_bar.set_hexpand(true); + top_bar.append(&titles); + top_bar.append(&info_toggle); + top_bar.append(&download_btn); + top_bar.append(&open_ext_btn); + top_bar.append(&close_btn); + overlay.add_overlay(&top_bar); + + // Details drawer: slides in from the right as a real surface (not a wash of + // black over the photo), with its own header and its own way out. + let info_rows = gtk4::Box::new(gtk4::Orientation::Vertical, 12); + + let info_map = gtk4::Button::builder() + .label("Show on map") + .icon_name("map-symbolic") + .halign(gtk4::Align::Start) + .build(); + info_map.add_css_class("pill"); + info_map.set_visible(false); + + let info_title = gtk4::Label::builder() + .label("Details") + .halign(gtk4::Align::Start) + .hexpand(true) + .build(); + info_title.add_css_class("heading"); + + let info_close = gtk4::Button::builder() + .icon_name("window-close-symbolic") + .tooltip_text("Hide details") + .build(); + info_close.add_css_class("flat"); + info_close.add_css_class("circular"); + + let info_header = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); + info_header.append(&info_title); + info_header.append(&info_close); + + let info_body = gtk4::Box::new(gtk4::Orientation::Vertical, 12); + info_body.set_margin_top(16); + info_body.set_margin_bottom(16); + info_body.set_margin_start(16); + info_body.set_margin_end(16); + info_body.append(&info_header); + info_body.append(&info_rows); + info_body.append(&info_map); + + // Scrolled, because a photo with a full EXIF block plus a location can + // outgrow a short window. + let info_scroll = gtk4::ScrolledWindow::builder() + .hscrollbar_policy(gtk4::PolicyType::Never) + .propagate_natural_height(true) + .child(&info_body) + .build(); + + let info_panel = gtk4::Box::new(gtk4::Orientation::Vertical, 0); + info_panel.add_css_class("viewer-info-panel"); + info_panel.set_width_request(320); + info_panel.append(&info_scroll); + + let info_revealer = gtk4::Revealer::builder() + .transition_type(gtk4::RevealerTransitionType::SlideLeft) + .halign(gtk4::Align::End) + .valign(gtk4::Align::Fill) + .child(&info_panel) + .build(); + overlay.add_overlay(&info_revealer); + + let spinner = gtk4::Spinner::builder() + .halign(gtk4::Align::Center) + .valign(gtk4::Align::Center) + .width_request(48) + .height_request(48) + .build(); + spinner.add_css_class("viewer-spinner"); + overlay.add_overlay(&spinner); + + let status_label = gtk4::Label::builder() + .wrap(true) + .justify(gtk4::Justification::Center) + .halign(gtk4::Align::Center) + .valign(gtk4::Align::Center) + .build(); + status_label.add_css_class("viewer-status"); + overlay.add_overlay(&status_label); + + window.set_child(Some(&overlay)); + + let viewer = Rc::new(Viewer { + picture: picture.clone(), + spinner, + status: status_label, + title: title_label, + counter: counter_label, + prev: prev_btn.clone(), + next: next_btn.clone(), + info_toggle: info_toggle.clone(), + info_revealer: info_revealer.clone(), + info_rows, + info_map: info_map.clone(), + coords: RefCell::new(None), + uid: RefCell::new(initial_uid.clone()), + path: RefCell::new(None), + loading: Cell::new(false), + }); + + let n = ui.gallery.model.n_items(); + let initial_idx = find_photo_index(&ui.gallery.model, &initial_uid).unwrap_or(0); + let capture_time = ui + .gallery.model + .item(initial_idx) + .and_downcast::() + .map_or(0, |boxed| boxed.borrow::().capture_time); + show_photo_position(&viewer, initial_idx, n, capture_time); + + load_photo(ui, &viewer, initial_uid); + prefetch_photo(ui, &viewer, 1); + + let w_close = window.clone(); + close_btn.connect_clicked(move |_| { + w_close.close(); + }); + + let viewer_info = viewer.clone(); + info_toggle.connect_toggled(move |toggle| { + viewer_info + .info_revealer + .set_reveal_child(toggle.is_active()); + }); + + let toggle_off = info_toggle.clone(); + info_close.connect_clicked(move |_| toggle_off.set_active(false)); + + let viewer_map = viewer.clone(); + info_map.connect_clicked(move |_| { + if let Some((lat, lon)) = *viewer_map.coords.borrow() { + open_path(&format!( + "https://www.openstreetmap.org/?mlat={lat:.6}&mlon={lon:.6}#map=16/{lat:.6}/{lon:.6}" + )); + } + }); + + let w_download = window.clone(); + let viewer_download = viewer.clone(); + download_btn.connect_clicked(move |_| { + if let Some(path) = viewer_download.path.borrow().as_deref() { + let name = format!("{}.jpg", viewer_download.uid.borrow()); + save_photo_to_disk(&w_download, path, &name); + } + }); + + let viewer_ext = viewer.clone(); + open_ext_btn.connect_clicked(move |_| { + if let Some(path) = viewer_ext.path.borrow().as_deref() { + open_path(path); + } + }); + + let ui_prev = ui.clone(); + let viewer_prev = viewer.clone(); + prev_btn.connect_clicked(move |_| { + navigate_photo(&ui_prev, &viewer_prev, -1); + }); + + let ui_next = ui.clone(); + let viewer_next = viewer.clone(); + next_btn.connect_clicked(move |_| { + navigate_photo(&ui_next, &viewer_next, 1); + }); + + // Click the backdrop — the dark area beside the photo — to dismiss, the way + // every other lightbox behaves. Clicks on the photo itself are left alone, so + // reaching for the image doesn't fling the window shut. + let backdrop = gtk4::GestureClick::new(); + let viewer_click = viewer.clone(); + let w_click = window.clone(); + backdrop.connect_released(move |_, _, x, y| { + if !over_photo(&viewer_click.picture, x, y) { + w_click.close(); + } + }); + picture.add_controller(backdrop); + + let key_controller = gtk4::EventControllerKey::new(); + let ui_key = ui.clone(); + let viewer_key = viewer.clone(); + let w_key = window.clone(); + key_controller.connect_key_pressed(move |_, key, _keycode, state| { + let ctrl = state.contains(gtk4::gdk::ModifierType::CONTROL_MASK); + match key.name().as_deref() { + Some("Left" | "Up" | "BackSpace") => navigate_photo(&ui_key, &viewer_key, -1), + Some("Right" | "Down" | "space") => navigate_photo(&ui_key, &viewer_key, 1), + Some("Home") => navigate_photo(&ui_key, &viewer_key, i32::MIN / 2), + Some("End") => navigate_photo(&ui_key, &viewer_key, i32::MAX / 2), + Some("i") => viewer_key + .info_toggle + .set_active(!viewer_key.info_toggle.is_active()), + Some("f" | "F11") => { + if w_key.is_fullscreen() { + w_key.unfullscreen(); + } else { + w_key.fullscreen(); + } + } + Some("Escape" | "q") => w_key.close(), + Some("w") if ctrl => w_key.close(), + _ => return glib::Propagation::Proceed, + } + glib::Propagation::Stop + }); + window.add_controller(key_controller); + + window.present(); +} + +/// Whether `(x, y)` — in `picture`'s coordinates — lands on the photo itself +/// rather than the backdrop around it. [`gtk4::ContentFit::Contain`] centres the +/// image and letterboxes the rest, so the drawn rectangle is the widget scaled +/// down by whichever axis binds. +pub(crate) fn over_photo(picture: >k4::Picture, x: f64, y: f64) -> bool { + let (width, height) = (f64::from(picture.width()), f64::from(picture.height())); + let Some(paintable) = picture.paintable() else { + return false; + }; + let (iw, ih) = ( + f64::from(paintable.intrinsic_width()), + f64::from(paintable.intrinsic_height()), + ); + if iw <= 0.0 || ih <= 0.0 { + return false; + } + + let scale = (width / iw).min(height / ih); + let (drawn_w, drawn_h) = (iw * scale, ih * scale); + let (left, top) = ((width - drawn_w) / 2.0, (height - drawn_h) / 2.0); + x >= left && x <= left + drawn_w && y >= top && y <= top + drawn_h +} diff --git a/crates/pdfs-gui/src/app/pages/photos.rs b/crates/pdfs-gui/src/app/pages/photos.rs new file mode 100644 index 0000000..c2de44d --- /dev/null +++ b/crates/pdfs-gui/src/app/pages/photos.rs @@ -0,0 +1,1452 @@ +use crate::*; + +pub(crate) struct GalleryState { + // Photos (gallery) page. + /// Every photo loaded so far, newest first — the order the lightbox's + /// prev/next walks. The visible day sections are derived from this. + pub(crate) model: gio::ListStore, + /// The day sections rendered by the Photos ListView, rebuilt from + /// [`Self::model`] by [`repaint_gallery`]. + pub(crate) groups: gio::ListStore, + /// Target row height in px, retuned by Ctrl+scroll / Ctrl+± (see + /// [`zoom_gallery`]). Each tile keeps its own aspect ratio; rows are + /// justified to the content width around this height. + pub(crate) tile: Cell, + /// Swaps the Photos content area between the timeline and its status page. + pub(crate) content: gtk4::Stack, + pub(crate) status: adw::StatusPage, + pub(crate) retry: gtk4::Button, + pub(crate) more: gtk4::Button, + pub(crate) upload: gtk4::Button, + /// "1,204 photos" under the page title. + pub(crate) subtitle: gtk4::Label, + /// Which tab (Photos / Videos / Raw) the timeline is filtered to, or `None` + /// for All. Read by [`load_gallery`] and set by the filter toggles. + pub(crate) kind: Cell>, + /// The filter toggles, index-aligned with [`kind_for_tab`], kept so their + /// labels can carry live per-kind counts. + pub(crate) tabs: [gtk4::ToggleButton; 4], + /// The date-jump dropdown ("All dates" then a month per timeline entry), and + /// the `[from, to)` window each of its rows selects (index-aligned; `None` is + /// "All dates"). Selecting a row loads that month via [`load_gallery`]. + pub(crate) dates: gtk4::DropDown, + pub(crate) date_ranges: RefCell>>, + /// The capture-time window the timeline is currently filtered to, or `None` + /// for the whole span. Read by [`load_gallery`], set by the date dropdown. + pub(crate) range: Cell>, + /// Set while the date dropdown is being repopulated, so resetting its model + /// doesn't fire the selection handler and kick off a spurious reload. + pub(crate) date_suppress: Cell, + /// True while a timeline page is in flight, so the scroll-to-the-end paging + /// can't fire a second request for the page already coming. + pub(crate) loading: Cell, + /// Content width the sections are currently justified to. Updated when the + /// ListView is resized, which re-justifies the visible sections. + pub(crate) width: Cell, + /// Decoded thumbnails by photo uid, with the insertion order that evicts the + /// oldest past [`TEXTURE_CACHE_MAX`]. Scrolling back over a day therefore + /// repaints from memory instead of re-decoding from disk. + pub(crate) photo_tex: RefCell>, + pub(crate) photo_tex_order: RefCell>, + /// Aspect ratio (w/h) per uid, learned when a thumbnail is decoded and + /// persisted to disk, so a relaunch justifies its rows correctly on the first + /// frame instead of reflowing as thumbnails arrive. + pub(crate) photo_ratio: RefCell>, + /// Ratios learned since the last save, so the ratio file is only rewritten + /// when it actually changed. + pub(crate) photo_ratio_dirty: Cell, + /// Photos the daemon reported as having no thumbnail at all, so a tile that + /// can never be filled isn't requested again on every scroll past it. + pub(crate) photo_nothumb: RefCell>, + /// Tiles on screen still waiting for their thumbnail, by uid. Populated as + /// sections are bound, drained as batches land, cleared on unbind — so a + /// batch only ever paints a widget that is still showing that photo. + pub(crate) thumb_wanted: RefCell>, + /// Uids queued for the next [`Request::PhotoThumbs`] batch, and whether a + /// batch is already in flight (only one at a time, so a long scroll can't + /// stack requests on the daemon). + pub(crate) thumb_queue: RefCell>, + pub(crate) thumb_inflight: Cell, + /// Thumbnails on disk waiting to be turned into textures, as `(uid, path)`. + /// Decoding happens on the GTK thread (textures are not `Send`), so it is fed + /// a few at a time from an idle callback rather than in one blocking burst + /// that would stutter the scroll. [`Self::decode_idle`] is the "callback + /// already scheduled" guard. + pub(crate) decode_queue: RefCell>, + pub(crate) decode_idle: Cell, + /// Pending debounce timers for the thumbnail queue flush and the section + /// re-justify, replaced on each new trigger so only the last one fires. + pub(crate) thumb_source: RefCell>, + pub(crate) relayout_source: RefCell>, + /// The day sections currently realised by the ListView, by their index in + /// [`Self::groups`]. A learned ratio or a resize re-justifies these + /// in place — rebuilding the ListStore instead would reset the scroll + /// position out from under the user. + pub(crate) bound: RefCell>, +} + +/// How many photos to pull per [`Request::PhotosTimeline`] page. +pub(crate) const PHOTOS_PAGE: usize = 60; + +/// Gallery row height in px: the zoom range, its default, and the step one +/// Ctrl+scroll notch (or Ctrl+±) moves it by. Rows are justified to the full +/// content width, so this is the *target* height a row lands near, not an exact +/// tile size (see [`justify_rows`]). +pub(crate) const TILE_MIN: i32 = 90; + +pub(crate) const TILE_MAX: i32 = 340; + +pub(crate) const TILE_DEFAULT: i32 = 220; + +pub(crate) const TILE_STEP: i32 = 30; + +/// Gap between tiles, horizontally and vertically. +pub(crate) const TILE_GAP: i32 = 6; + +/// Aspect ratio (w/h) assumed for a photo whose thumbnail hasn't been decoded +/// yet, so a tile can be laid out before its image exists. Landscape 3:2, the +/// commonest camera/phone ratio; once the thumbnail lands the real ratio +/// replaces it and the section re-justifies in place. +pub(crate) const RATIO_UNKNOWN: f64 = 1.5; + +/// Aspect ratios are clamped to this range before a row is packed: one absurd +/// panorama, or a sliver of a portrait, must not be able to squash the row it +/// lands in down to nothing. The tile still shows the whole image. +pub(crate) const RATIO_MIN: f64 = 0.4; + +pub(crate) const RATIO_MAX: f64 = 3.5; + +/// How many thumbnails one on-demand [`Request::PhotoThumbs`] batch asks for. +/// Small, so the first tiles on screen fill in quickly rather than the whole +/// page landing at once. +pub(crate) const THUMB_BATCH: usize = 16; + +/// Idle pause before a thumbnail batch is sent, so a fast scroll coalesces into +/// one request per settle instead of one per row that flickers past. +pub(crate) const THUMB_DEBOUNCE: Duration = Duration::from_millis(60); + +/// How long to wait before asking again for a thumbnail the daemon is generating +/// itself. That means downloading the photo's full file, so the wait is measured +/// in seconds, not milliseconds. +pub(crate) const THUMB_RETRY: Duration = Duration::from_secs(4); + +/// Decoded thumbnails held in memory. Each is a few hundred KiB of GPU texture; +/// this caps the gallery's footprint while covering several screens of scroll. +pub(crate) const TEXTURE_CACHE_MAX: usize = 600; + +/// Pause after a resize/zoom before the visible sections are re-justified. +pub(crate) const RELAYOUT_DEBOUNCE: Duration = Duration::from_millis(80); + +/// One day-section of the photos timeline: a heading plus the photos captured +/// that day, in timeline order. Built from the flat [`Ui::gallery_model`] by +/// [`group_photos`] and rendered as one [`gtk4::ListView`] row. +pub(crate) struct PhotoGroup { + /// "Today", "Yesterday", or e.g. "3 June 2026". + pub(crate) heading: String, + pub(crate) photos: Vec, +} + +/// The widgets [`build_gallery_page`] hands back to [`build_window`]. +pub(crate) struct GalleryWidgets { + /// Flat, newest-first list of every loaded photo. Backs the lightbox's + /// prev/next navigation; the visible sections are derived from it. + pub(crate) model: gio::ListStore, + /// Day sections rendered by the ListView, derived from `model`. + pub(crate) groups: gio::ListStore, + /// Swaps between the timeline and the empty/loading/error status page. + pub(crate) content: gtk4::Stack, + pub(crate) status: adw::StatusPage, + pub(crate) subtitle: gtk4::Label, + pub(crate) more: gtk4::Button, + pub(crate) list: gtk4::ListView, + pub(crate) scroll: gtk4::ScrolledWindow, + pub(crate) retry: gtk4::Button, + pub(crate) upload: gtk4::Button, + pub(crate) refresh: gtk4::Button, + /// The All / Photos / Videos / Raw filter toggles, in that order (index maps + /// to [`kind_for_tab`]). + pub(crate) tabs: [gtk4::ToggleButton; 4], + /// The date-jump dropdown, populated with the timeline's months. + pub(crate) dates: gtk4::DropDown, +} + +/// The Photos page: a [`gtk4::ListView`] of day sections, each a heading over +/// that day's photos laid out as justified rows (see [`justify_rows`]) — every +/// photo at its own aspect ratio, every row filled edge to edge. +/// +/// A ListView of sections rather than one flat GridView because GTK's grid has no +/// row headers and forces square cells: the justified rows and the date headings +/// both need per-row structure, and the ListView only realises the sections on +/// screen, which is what keeps a 10,000-photo timeline cheap. The factory is +/// installed by [`wire_gallery`], which has the [`Ui`] the tiles need (zoom +/// level, thumbnail cache, click-to-open). +pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) { + let model = gio::ListStore::new::(); + let groups = gio::ListStore::new::(); + + let selection = gtk4::NoSelection::new(Some(groups.clone())); + let list = gtk4::ListView::builder() + .model(&selection) + .single_click_activate(false) + .build(); + list.add_css_class("gallery-sections"); + + // Shown only when a load failed because the mount is down; restarts it. + let retry = gtk4::Button::builder() + .label("Retry") + .halign(gtk4::Align::Center) + .build(); + retry.add_css_class("pill"); + retry.add_css_class("suggested-action"); + retry.set_visible(false); + + let status = adw::StatusPage::builder() + .icon_name("image-x-generic-symbolic") + .vexpand(true) + .child(&retry) + .build(); + status.add_css_class("compact"); + + // Kept as an explicit fallback: the timeline also pages itself in as the + // scroll nears the bottom (see [`wire_gallery`]), so reaching this button at + // all is unusual. + let more = gtk4::Button::builder() + .label("Load more") + .halign(gtk4::Align::Center) + .build(); + more.add_css_class("pill"); + more.set_visible(false); + + // Horizontal scrolling is never wanted: rows are justified to the viewport + // width, and a stray hscrollbar would fight the layout. + let scroll = gtk4::ScrolledWindow::builder() + .vexpand(true) + .hscrollbar_policy(gtk4::PolicyType::Never) + .child(&list) + .build(); + + let title_label = gtk4::Label::builder() + .label("Photos") + .halign(gtk4::Align::Start) + .build(); + title_label.add_css_class("title-2"); + + let subtitle = gtk4::Label::builder() + .halign(gtk4::Align::Start) + .visible(false) + .build(); + subtitle.add_css_class("dim-label"); + subtitle.add_css_class("caption"); + + let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 2); + titles.set_hexpand(true); + titles.append(&title_label); + titles.append(&subtitle); + + let upload = gtk4::Button::builder() + .label("Upload") + .icon_name("list-add-symbolic") + .valign(gtk4::Align::Center) + .build(); + upload.add_css_class("pill"); + upload.add_css_class("suggested-action"); + let refresh = refresh_button(); + + let header_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + header_box.append(&titles); + header_box.append(&refresh); + header_box.append(&upload); + + // All / Photos / Videos / Raw filter. Linked toggles acting as one segmented + // control: exactly one is active, and flipping it reloads the timeline + // filtered to that kind (wired in [`wire_gallery`]). Labels gain live counts + // once a page lands. + let tab_labels = ["All", "Photos", "Videos", "Raw"]; + let tabs: [gtk4::ToggleButton; 4] = std::array::from_fn(|i| { + gtk4::ToggleButton::builder() + .label(tab_labels[i]) + .active(i == 0) + .build() + }); + // Group the toggles so they behave as a radio set: chaining each to the first + // is what GTK turns into mutual exclusion. + for btn in &tabs[1..] { + btn.set_group(Some(&tabs[0])); + } + let tab_group = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + tab_group.add_css_class("linked"); + for btn in &tabs { + btn.add_css_class("pill"); + tab_group.append(btn); + } + + // Date jump: "All dates" plus a row per month, filled in once the timeline's + // months are known (see [`refresh_photo_months`]). Pushed to the far end of + // the filter row, opposite the kind toggles. + let dates = gtk4::DropDown::from_strings(&["All dates"]); + dates.add_css_class("pill"); + dates.set_tooltip_text(Some("Jump to a month")); + + let filter_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + filter_bar.append(&tab_group); + let spacer = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); + spacer.set_hexpand(true); + filter_bar.append(&spacer); + filter_bar.append(&dates); + + // The timeline (plus its pager) or the status page, never both. + let timeline = gtk4::Box::new(gtk4::Orientation::Vertical, 12); + timeline.append(&scroll); + timeline.append(&more); + + let content = gtk4::Stack::new(); + content.set_vexpand(true); + content.set_transition_type(gtk4::StackTransitionType::Crossfade); + content.add_named(&timeline, Some("timeline")); + content.add_named(&status, Some("status")); + + let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12); + inner.set_margin_top(12); + inner.set_margin_bottom(12); + inner.set_margin_start(12); + inner.set_margin_end(12); + inner.append(&header_box); + inner.append(&filter_bar); + inner.append(&content); + + ( + inner.upcast(), + GalleryWidgets { + model, + groups, + content, + status, + subtitle, + more, + list, + scroll, + retry, + upload, + refresh, + tabs, + dates, + }, + ) +} + +/// The [`PhotoKind`] filter a gallery tab index selects: index 0 is All (no +/// filter), then Photos / Videos / Raw. Index-aligned with the toggle array. +pub(crate) fn kind_for_tab(index: usize) -> Option { + match index { + 1 => Some(PhotoKind::Photo), + 2 => Some(PhotoKind::Video), + 3 => Some(PhotoKind::Raw), + _ => None, + } +} + +/// The `[from, to)` epoch-second window of a local calendar month, or `None` if +/// the date is somehow unrepresentable. Computed with glib so month rollover and +/// the local timezone (matching the daemon's month buckets) are handled for us. +pub(crate) fn month_range(year: i32, month: i32) -> Option<(i64, i64)> { + let start = glib::DateTime::from_local(year, month, 1, 0, 0, 0.0).ok()?; + let end = start.add_months(1).ok()?; + Some((start.to_unix(), end.to_unix())) +} + +/// English month names, indexed 1..=12. +pub(crate) const MONTH_NAMES: [&str; 12] = [ + "January", "February", "March", "April", "May", "June", "July", "August", "September", + "October", "November", "December", +]; + +/// Rebuild the date-jump dropdown for the active kind: ask the daemon which +/// months the timeline spans and turn them into "Month YYYY (count)" rows, each +/// remembering the window it jumps to. Resets the selection to "All dates" — the +/// caller pairs this with a fresh timeline load. Off the UI thread; a failure +/// just leaves the dropdown as it was. +pub(crate) fn refresh_photo_months(ui: &Rc) { + let rx = spawn_request( + ui.dirs.control_socket(), + Request::PhotoMonths { + kind: ui.gallery.kind.get(), + }, + ); + let ui = ui.clone(); + glib::spawn_future_local(async move { + let Ok(Ok(Response::PhotoMonths { months })) = rx.recv().await else { + return; + }; + let mut labels = vec!["All dates".to_string()]; + let mut ranges: Vec> = vec![None]; + for m in months { + let name = MONTH_NAMES.get((m.month - 1) as usize).copied().unwrap_or("?"); + labels.push(format!("{name} {} ({})", m.year, m.count)); + ranges.push(month_range(m.year, m.month)); + } + let label_refs: Vec<&str> = labels.iter().map(|s| s.as_str()).collect(); + + // Repopulating resets the selection to 0, which would otherwise fire the + // handler and reload; suppress that — the caller is already reloading. + ui.gallery.date_suppress.set(true); + ui.gallery.dates + .set_model(Some(>k4::StringList::new(&label_refs))); + ui.gallery.dates.set_selected(0); + *ui.gallery.date_ranges.borrow_mut() = ranges; + ui.gallery.date_suppress.set(false); + }); +} + +/// Label the filter toggles with the whole-timeline `(photos, videos, raw)` +/// counts, so a glance shows how much sits behind each tab. A tab with nothing +/// behind it is disabled — you can't filter to an empty set — but the currently +/// selected one stays clickable so you can always switch back off it. +pub(crate) fn update_gallery_tabs(ui: &Rc, counts: (usize, usize, usize)) { + let (photos, videos, raw) = counts; + let totals = [photos + videos + raw, photos, videos, raw]; + for (index, tab) in ui.gallery.tabs.iter().enumerate() { + let name = ["All", "Photos", "Videos", "Raw"][index]; + let n = totals[index]; + tab.set_label(&format!("{name} {n}")); + tab.set_sensitive(n > 0 || tab.is_active()); + } +} + +/// Wire the gallery: install the section factory, the zoom gestures, the pager +/// and the upload button. Activating a thumbnail downloads the photo and opens it +/// in the in-app lightbox. +pub(crate) fn wire_gallery(ui: &Rc, list: >k4::ListView, scroll: >k4::ScrolledWindow) { + let factory = gtk4::SignalListItemFactory::new(); + factory.connect_setup(|_, item| { + let item = item.downcast_ref::().unwrap(); + let section = gtk4::Box::new(gtk4::Orientation::Vertical, 8); + section.set_margin_bottom(16); + let heading = gtk4::Label::builder().halign(gtk4::Align::Start).build(); + heading.add_css_class("heading"); + heading.add_css_class("gallery-day"); + section.append(&heading); + item.set_child(Some(§ion)); + item.set_activatable(false); + }); + + let ui_bind = ui.clone(); + factory.connect_bind(move |_, item| { + let item = item.downcast_ref::().unwrap(); + let section = item.child().and_downcast::().unwrap(); + let obj = item.item().and_downcast::().unwrap(); + let group = obj.borrow::(); + + let heading = section.first_child().and_downcast::().unwrap(); + heading.set_label(&group.heading); + + fill_section(&ui_bind, §ion, &group.photos); + // Remember the realised section so a learned aspect ratio or a resize can + // re-justify it in place, without rebuilding the ListStore (which would + // yank the scroll position back to the top). + ui_bind + .gallery.bound + .borrow_mut() + .insert(item.position(), section); + }); + + // ListView recycles section widgets, so a scrolled-away day must give up its + // claim on the widgets — otherwise a thumbnail landing late would paint into + // a tile that now shows a different day. + let ui_unbind = ui.clone(); + factory.connect_unbind(move |_, item| { + let item = item.downcast_ref::().unwrap(); + ui_unbind + .gallery.bound + .borrow_mut() + .remove(&item.position()); + if let Some(obj) = item.item().and_downcast::() { + let group = obj.borrow::(); + let mut wanted = ui_unbind.gallery.thumb_wanted.borrow_mut(); + for photo in &group.photos { + wanted.remove(&photo.uid); + } + } + }); + list.set_factory(Some(&factory)); + + // Rows are justified to the content width, so a resize re-justifies whatever + // is on screen (offscreen sections pick the new width up when they bind). + let ui_width = ui.clone(); + list.connect_notify_local(Some("width"), move |list, _| { + let width = list.width(); + if width > 0 && width != ui_width.gallery.width.get() { + ui_width.gallery.width.set(width); + schedule_relayout(&ui_width); + } + }); + + // Page the timeline in as the scroll nears the end, so "load more" is a + // fallback button rather than something the user has to hunt for. + let ui_scroll = ui.clone(); + scroll.vadjustment().connect_value_changed(move |adj| { + let near_end = adj.value() + adj.page_size() >= adj.upper() - adj.page_size() * 0.5; + if near_end && ui_scroll.gallery.more.is_visible() && ui_scroll.gallery.more.is_sensitive() + { + load_gallery(&ui_scroll, true); + } + }); + + // Ctrl+scroll zoom. Capture phase so the ScrolledWindow doesn't eat the event + // and scroll the page out from under the gesture. + let zoom_scroll = gtk4::EventControllerScroll::new(gtk4::EventControllerScrollFlags::VERTICAL); + zoom_scroll.set_propagation_phase(gtk4::PropagationPhase::Capture); + let ui_zoom = ui.clone(); + zoom_scroll.connect_scroll(move |controller, _dx, dy| { + if !controller + .current_event_state() + .contains(gtk4::gdk::ModifierType::CONTROL_MASK) + || dy == 0.0 + { + return glib::Propagation::Proceed; + } + // Scroll up (negative dy) zooms in, i.e. bigger tiles. + zoom_gallery(&ui_zoom, if dy < 0.0 { TILE_STEP } else { -TILE_STEP }); + glib::Propagation::Stop + }); + scroll.add_controller(zoom_scroll); + + // Ctrl+plus / Ctrl+minus / Ctrl+0, the keyboard equivalents. + let zoom_keys = gtk4::EventControllerKey::new(); + let ui_keys = ui.clone(); + zoom_keys.connect_key_pressed(move |_, key, _code, state| { + if !state.contains(gtk4::gdk::ModifierType::CONTROL_MASK) { + return glib::Propagation::Proceed; + } + match key.name().as_deref() { + Some("plus" | "equal" | "KP_Add") => zoom_gallery(&ui_keys, TILE_STEP), + Some("minus" | "KP_Subtract") => zoom_gallery(&ui_keys, -TILE_STEP), + Some("0" | "KP_0") => set_gallery_tile(&ui_keys, TILE_DEFAULT), + _ => return glib::Propagation::Proceed, + } + glib::Propagation::Stop + }); + list.add_controller(zoom_keys); + + let ui_more = ui.clone(); + ui.gallery.more.clone().connect_clicked(move |_| { + load_gallery(&ui_more, true); + }); + + // Filter toggles: flipping to a tab reloads the timeline filtered to that + // kind. Only the button being switched *on* acts — the group also fires a + // `toggled` for the one switching off, which this skips — and a redundant + // toggle to the already-current kind is a no-op. + for (index, tab) in ui.gallery.tabs.iter().enumerate() { + let ui_tab = ui.clone(); + tab.connect_toggled(move |btn| { + if !btn.is_active() { + return; + } + let kind = kind_for_tab(index); + if ui_tab.gallery.kind.get() == kind { + return; + } + ui_tab.gallery.kind.set(kind); + // A different kind has a different set of months; clearing the active + // window makes the reload rebuild the date jump for the new kind. + ui_tab.gallery.range.set(None); + load_gallery(&ui_tab, false); + }); + } + + // Date jump: selecting a month loads that window; "All dates" (row 0) clears + // it. Skipped while the model is being repopulated (see `gallery_date_suppress`). + let ui_dates = ui.clone(); + ui.gallery.dates.connect_selected_notify(move |dd| { + if ui_dates.gallery.date_suppress.get() { + return; + } + let range = ui_dates + .gallery.date_ranges + .borrow() + .get(dd.selected() as usize) + .copied() + .flatten(); + if ui_dates.gallery.range.get() == range { + return; + } + ui_dates.gallery.range.set(range); + load_gallery(&ui_dates, false); + }); + + let ui_upload = ui.clone(); + ui.gallery.upload.connect_clicked(move |_| { + let dialog = gtk4::FileDialog::builder() + .title("Select Photo to Upload") + .build(); + + let filter = gtk4::FileFilter::new(); + filter.set_name(Some("Images")); + filter.add_mime_type("image/*"); + let filters = gio::ListStore::new::(); + filters.append(&filter); + dialog.set_filters(Some(&filters)); + + let ui = ui_upload.clone(); + 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); + + 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); + toast(&ui_clone, "Photo uploaded"); + } + Ok(Ok(Response::Error { message, kind })) => { + toast_failure(&ui_clone, "Couldn't upload photo", &message, kind); + } + _ => { + toast_error( + &ui_clone, + "Couldn't upload photo", + "The mount service didn't respond.", + ); + } + } + }); + } + }); + }); +} + +/// One tile in a justified row: the photo, and the pixel size it was justified to. +pub(crate) struct Tile { + pub(crate) photo: PhotoItem, + pub(crate) width: i32, + pub(crate) height: i32, +} + +/// Break one day's photos into rows that each fill `width` exactly, at heights +/// near [`Ui::gallery_tile`] — the layout every modern photo gallery uses, and +/// the reason nothing here has to be cropped: each tile keeps its own aspect +/// ratio, and it is the row *height* that flexes to make the widths add up. +/// +/// Greedy, one pass: keep adding photos to the row: the more photos share it, the +/// shorter it has to be to fit. The moment that height drops to the target, the +/// row is as full as it should be and is emitted. The trailing row keeps the +/// target height instead of being stretched, so a day with three photos doesn't +/// blow them up to fill a screen-wide row. +/// +/// Photos whose thumbnail hasn't been decoded yet are laid out at +/// [`RATIO_UNKNOWN`]; [`Ui::store_texture`] reports the real ratio when it lands +/// and the section is re-justified in place. +pub(crate) fn justify_rows(ui: &Rc, photos: &[PhotoItem], width: i32) -> Vec> { + let ratios: Vec = photos.iter().map(|photo| ui.ratio(&photo.uid)).collect(); + let target = f64::from(ui.gallery.tile.get()); + let plan = plan_rows(&ratios, target, f64::from(width)); + + let mut photos = photos.iter(); + plan.into_iter() + .map(|row| { + row.into_iter() + .filter_map(|(width, height)| { + photos.next().map(|photo| Tile { + photo: photo.clone(), + width, + height, + }) + }) + .collect() + }) + .collect() +} + +/// The row-packing math behind [`justify_rows`], over nothing but aspect ratios: +/// takes each photo's ratio (w/h) in order and returns the pixel `(width, +/// height)` of every tile, grouped into rows. +pub(crate) fn plan_rows(ratios: &[f64], target: f64, width: f64) -> Vec> { + let avail = width.max(f64::from(TILE_MIN)); + let gap = f64::from(TILE_GAP); + + // The height a row of `n` photos with `sum_ratio` total ratio must take for + // its widths (plus gaps) to add up to exactly `avail`. + let row_height = |sum_ratio: f64, n: usize| { + let gaps = gap * (n.saturating_sub(1)) as f64; + (avail - gaps) / sum_ratio + }; + + let mut rows: Vec> = Vec::new(); + let mut row: Vec = Vec::new(); + let mut sum_ratio = 0.0; + + for ratio in ratios { + let ratio = ratio.clamp(RATIO_MIN, RATIO_MAX); + row.push(ratio); + sum_ratio += ratio; + + let height = row_height(sum_ratio, row.len()); + if height <= target { + rows.push(size_row(&row, height, avail, true)); + row.clear(); + sum_ratio = 0.0; + } + } + if !row.is_empty() { + let height = row_height(sum_ratio, row.len()).min(target); + rows.push(size_row(&row, height, avail, false)); + } + rows +} + +/// Size one row's tiles at `height`. A `justified` row is nudged so its widths +/// plus gaps hit `avail` exactly — rounding each width independently leaves a +/// few px of ragged right edge, so the last tile absorbs the remainder. +pub(crate) fn size_row(ratios: &[f64], height: f64, avail: f64, justified: bool) -> Vec<(i32, i32)> { + let height = height.round().max(1.0); + let mut sizes: Vec<(i32, i32)> = ratios + .iter() + .map(|ratio| ((ratio * height).round().max(1.0) as i32, height as i32)) + .collect(); + + if justified { + let gaps = TILE_GAP * (sizes.len().saturating_sub(1)) as i32; + let used: i32 = sizes.iter().rev().skip(1).map(|(width, _)| *width).sum(); + if let Some(last) = sizes.last_mut() { + last.0 = (avail as i32 - gaps - used).max(1); + } + } + sizes +} + +/// (Re)build a bound day-section's tiles: justify this day's photos to the +/// current content width and hand each tile whatever thumbnail is already in +/// memory, queueing the rest. Replaces the section's rows in place, leaving the +/// heading — so a re-justify never touches the ListView's model or scroll. +pub(crate) fn fill_section(ui: &Rc, section: >k4::Box, photos: &[PhotoItem]) { + let Some(heading) = section.first_child() else { + return; + }; + while let Some(old) = heading.next_sibling() { + section.remove(&old); + } + + let width = gallery_width(ui); + let rows = gtk4::Box::new(gtk4::Orientation::Vertical, TILE_GAP); + for row in justify_rows(ui, photos, width) { + let row_box = gtk4::Box::new(gtk4::Orientation::Horizontal, TILE_GAP); + for tile in row { + row_box.append(&photo_tile(ui, tile)); + } + rows.append(&row_box); + } + section.append(&rows); + schedule_thumbs(ui); +} + +/// The width justified rows are laid out to: the ListView's own width, less a +/// couple of px so a rounding error can't push a row into a horizontal overflow. +/// Falls back to a sane guess before the first allocation. +pub(crate) fn gallery_width(ui: &Rc) -> i32 { + match ui.gallery.width.get() { + 0 => 900, + w => (w - 2).max(TILE_MIN), + } +} + +/// One photo tile: a fixed-size button wrapping the thumbnail, with the capture +/// time revealed on hover over a bottom scrim. A button (rather than a bare +/// picture) so the tile is focusable, keyboard-activatable and gets hover feedback +/// for free. +/// +/// The picture sits in an overlay over a placeholder, so a tile is never a hole: +/// until the thumbnail lands it shows a dim card, and a photo that can never have +/// one keeps an image glyph instead of an empty rectangle. +pub(crate) fn photo_tile(ui: &Rc, tile: Tile) -> gtk4::Button { + let picture = gtk4::Picture::builder() + // The tile is exactly the thumbnail's own aspect ratio, so Cover scales + // it and crops nothing; it only bites during the brief window where the + // ratio is still a guess, and the re-justify then fixes the tile. + .content_fit(gtk4::ContentFit::Cover) + .can_shrink(true) + .build(); + + let placeholder = gtk4::Image::builder() + .icon_name("image-x-generic-symbolic") + .pixel_size(24) + .halign(gtk4::Align::Center) + .valign(gtk4::Align::Center) + .build(); + placeholder.add_css_class("photo-placeholder"); + + // The capture time, on a gradient that only exists while the pointer is over + // the tile — legible over any photo, invisible the rest of the time. + let caption = gtk4::Label::builder() + // Fill horizontally so the scrim spans the tile; the text itself stays + // left-aligned inside it. + .halign(gtk4::Align::Fill) + .valign(gtk4::Align::End) + .xalign(0.0) + .label(short_capture_time(tile.photo.capture_time)) + .ellipsize(gtk4::pango::EllipsizeMode::End) + .build(); + caption.add_css_class("photo-caption"); + + let overlay = gtk4::Overlay::new(); + overlay.set_child(Some(&placeholder)); + overlay.add_overlay(&picture); + overlay.add_overlay(&caption); + + // A video reads as a video at a glance: a play glyph centred over the poster + // thumbnail. Kept above the caption scrim so it stays legible on hover. + let is_video = tile.photo.kind == PhotoKind::Video; + if is_video { + let badge = gtk4::Image::builder() + .icon_name("media-playback-start-symbolic") + .pixel_size(28) + .halign(gtk4::Align::Center) + .valign(gtk4::Align::Center) + .build(); + badge.add_css_class("photo-video-badge"); + overlay.add_overlay(&badge); + } + + let button = gtk4::Button::builder() + .child(&overlay) + .width_request(tile.width) + .height_request(tile.height) + .tooltip_text(format_capture_time(tile.photo.capture_time)) + .build(); + button.add_css_class("photo-tile"); + button.add_css_class("flat"); + // Clip the thumbnail to the tile's rounded corners. + button.set_overflow(gtk4::Overflow::Hidden); + + want_thumb(ui, &tile.photo, &picture); + + // A still opens in the in-app lightbox; a video can't render there, so it + // downloads and hands off to an external player instead. + let ui_open = ui.clone(); + let uid = tile.photo.uid.clone(); + button.connect_clicked(move |_| { + if is_video { + play_video(&ui_open, uid.clone()); + } else { + open_photo_viewer(&ui_open, uid.clone()); + } + }); + button +} + +/// Give `picture` its thumbnail: straight from the texture cache when it's there, +/// otherwise register the tile as waiting and get the thumbnail moving — decoding +/// it if the daemon already had it cached on disk, or asking the daemon for it. +/// +/// This is what makes the gallery on-demand: only tiles the ListView actually +/// realises ever ask for an image. +pub(crate) fn want_thumb(ui: &Rc, photo: &PhotoItem, picture: >k4::Picture) { + if let Some(texture) = ui.gallery.photo_tex.borrow().get(&photo.uid) { + picture.set_paintable(Some(texture)); + return; + } + // No thumbnail will ever come for this one — not from the server, and not + // from the daemon's own scaling of the file. The tile keeps its placeholder + // glyph, and stays clickable: the full photo may still open fine. + if ui.gallery.photo_nothumb.borrow().contains(&photo.uid) { + return; + } + + ui.gallery.thumb_wanted + .borrow_mut() + .insert(photo.uid.clone(), picture.clone()); + + match photo.thumb_path.as_deref() { + Some(path) => { + ui.gallery.decode_queue + .borrow_mut() + .push_back((photo.uid.clone(), path.to_string())); + schedule_decode(ui); + } + None => { + let mut queue = ui.gallery.thumb_queue.borrow_mut(); + if !queue.contains(&photo.uid) { + queue.push_back(photo.uid.clone()); + } + } + } +} + +/// Come back for thumbnails the daemon is still generating — it is downloading +/// each photo's full file to scale it, which takes far longer than a batch. The +/// tiles keep their placeholder until then, and a tile that has scrolled away is +/// dropped by [`flush_thumbs`] like any other queued uid. +pub(crate) fn retry_pending_thumbs(ui: &Rc, uids: Vec) { + let ui = ui.clone(); + glib::timeout_add_local_once(THUMB_RETRY, move || { + { + let mut queue = ui.gallery.thumb_queue.borrow_mut(); + for uid in uids { + if !queue.contains(&uid) { + queue.push_back(uid); + } + } + } + schedule_thumbs(&ui); + }); +} + +/// Ask the daemon for the queued thumbnails after a short pause, so a fast scroll +/// coalesces into one batch per settle rather than one per row it flew past. +pub(crate) fn schedule_thumbs(ui: &Rc) { + if ui.gallery.thumb_queue.borrow().is_empty() || ui.gallery.thumb_inflight.get() { + return; + } + if let Some(id) = ui.gallery.thumb_source.borrow_mut().take() { + id.remove(); + } + let ui_flush = ui.clone(); + let source = glib::timeout_add_local_once(THUMB_DEBOUNCE, move || { + ui_flush.gallery.thumb_source.borrow_mut().take(); + flush_thumbs(&ui_flush); + }); + *ui.gallery.thumb_source.borrow_mut() = Some(source); +} + +/// Send one [`Request::PhotoThumbs`] batch for the tiles still on screen. Queued +/// uids whose tile has scrolled away are dropped rather than fetched: the point +/// of the batch is what the user is looking at *now*. +pub(crate) fn flush_thumbs(ui: &Rc) { + if ui.gallery.thumb_inflight.get() { + return; + } + let uids: Vec = { + let mut queue = ui.gallery.thumb_queue.borrow_mut(); + let wanted = ui.gallery.thumb_wanted.borrow(); + let mut batch = Vec::new(); + while batch.len() < THUMB_BATCH { + let Some(uid) = queue.pop_front() else { break }; + if wanted.contains_key(&uid) { + batch.push(uid); + } + } + batch + }; + if uids.is_empty() { + return; + } + + ui.gallery.thumb_inflight.set(true); + let rx = spawn_request(ui.dirs.control_socket(), Request::PhotoThumbs { uids }); + let ui = ui.clone(); + glib::spawn_future_local(async move { + let result = rx.recv().await; + ui.gallery.thumb_inflight.set(false); + match result { + Ok(Ok(Response::Thumbs { items })) => { + let mut decode = ui.gallery.decode_queue.borrow_mut(); + let mut nothumb = ui.gallery.photo_nothumb.borrow_mut(); + let mut pending = Vec::new(); + for item in items { + match item.path { + Some(path) => decode.push_back((item.uid, path)), + // The daemon is making this one itself, from the photo's + // full file — that takes a download, so come back for it. + None if item.pending => pending.push(item.uid), + // No thumbnail exists and none can be made: remember that, + // so scrolling past the tile doesn't re-ask forever. + None => { + nothumb.insert(item.uid); + } + } + } + drop((decode, nothumb)); + schedule_decode(&ui); + if !pending.is_empty() { + retry_pending_thumbs(&ui, pending); + } + } + // A thumbnail that doesn't arrive is not worth a toast — the tile just + // stays a placeholder, and the next scroll past it tries again. + Ok(Ok(Response::Error { message, .. })) => { + tracing::debug!("photo thumbs failed: {message}") + } + Ok(Ok(_)) | Ok(Err(_)) | Err(_) => tracing::debug!("photo thumbs: no reply"), + } + // Whatever the batch did, more tiles may have queued up behind it. + schedule_thumbs(&ui); + }); +} + +/// Decode queued thumbnails into textures on an idle callback, a few per pass, so +/// a big batch fills in progressively instead of freezing the scroll for the +/// length of the whole decode. +pub(crate) fn schedule_decode(ui: &Rc) { + if ui.gallery.decode_idle.get() || ui.gallery.decode_queue.borrow().is_empty() { + return; + } + ui.gallery.decode_idle.set(true); + + let ui = ui.clone(); + glib::idle_add_local(move || { + let batch: Vec<(String, String)> = { + let mut queue = ui.gallery.decode_queue.borrow_mut(); + (0..4).filter_map(|_| queue.pop_front()).collect() + }; + let mut relayout = false; + for (uid, path) in batch { + let texture = match gtk4::gdk::Texture::from_filename(&path) { + Ok(texture) => texture, + Err(e) => { + tracing::debug!("cannot decode thumbnail {path}: {e}"); + ui.gallery.photo_nothumb.borrow_mut().insert(uid); + continue; + } + }; + // A ratio we hadn't seen means the tile was sized against a guess. + relayout |= ui.store_texture(&uid, texture.clone()); + if let Some(picture) = ui.gallery.thumb_wanted.borrow_mut().remove(&uid) { + picture.set_paintable(Some(&texture)); + } + } + if relayout { + schedule_relayout(&ui); + } + + if ui.gallery.decode_queue.borrow().is_empty() { + ui.gallery.decode_idle.set(false); + ui.save_ratios(); + return glib::ControlFlow::Break; + } + glib::ControlFlow::Continue + }); +} + +/// Re-justify the sections on screen shortly. Debounced, because the triggers +/// (a window resize, a zoom step, a burst of decoded thumbnails) all arrive in +/// floods and only the final state matters. +pub(crate) fn schedule_relayout(ui: &Rc) { + if let Some(id) = ui.gallery.relayout_source.borrow_mut().take() { + id.remove(); + } + let ui_relayout = ui.clone(); + let source = glib::timeout_add_local_once(RELAYOUT_DEBOUNCE, move || { + ui_relayout.gallery.relayout_source.borrow_mut().take(); + relayout_gallery(&ui_relayout); + }); + *ui.gallery.relayout_source.borrow_mut() = Some(source); +} + +/// Rebuild the tiles of the day sections currently on screen, at the current +/// width, zoom and set of known aspect ratios. Sections that are *not* realised +/// need no work: they justify themselves against the current state when the +/// ListView binds them. +pub(crate) fn relayout_gallery(ui: &Rc) { + let bound: Vec<(u32, gtk4::Box)> = ui + .gallery.bound + .borrow() + .iter() + .map(|(pos, section)| (*pos, section.clone())) + .collect(); + for (pos, section) in bound { + let Some(obj) = ui.gallery.groups.item(pos) else { + continue; + }; + let Some(boxed) = obj.downcast_ref::() else { + continue; + }; + let photos = boxed.borrow::().photos.clone(); + fill_section(ui, §ion, &photos); + } +} + +/// Step the row height by `delta` px and re-justify, clamped to the zoom range. +pub(crate) fn zoom_gallery(ui: &Rc, delta: i32) { + set_gallery_tile(ui, ui.gallery.tile.get() + delta); +} + +/// Set the row height (clamped) and re-justify the visible sections at it. +pub(crate) fn set_gallery_tile(ui: &Rc, tile: i32) { + let tile = tile.clamp(TILE_MIN, TILE_MAX); + if tile == ui.gallery.tile.get() { + return; + } + ui.gallery.tile.set(tile); + schedule_relayout(ui); +} + +/// Rebuild the day sections from the flat photo model. The timeline arrives +/// newest-first, so photos of the same day are already contiguous — one pass +/// splits them. +/// +/// The groups are diffed into the existing store rather than replacing it: a +/// "load more" only really changes the last day (the one the new page continues) +/// and appends after it, and clearing the store instead would scroll the user +/// back to the top of the timeline at the exact moment they asked for more. +pub(crate) fn repaint_gallery(ui: &Rc) { + let groups = group_photos(&ui.gallery.model); + let store = &ui.gallery.groups; + + for (i, group) in groups.iter().enumerate() { + let i = i as u32; + let unchanged = store + .item(i) + .and_downcast::() + .is_some_and(|old| { + let old = old.borrow::(); + old.heading == group.heading && old.photos.len() == group.photos.len() + }); + if unchanged { + continue; + } + let boxed = BoxedAnyObject::new(PhotoGroup { + heading: group.heading.clone(), + photos: group.photos.clone(), + }); + if i < store.n_items() { + store.splice(i, 1, &[boxed]); + } else { + store.append(&boxed); + } + } + // Photos only ever get appended, so a shorter model means a fresh load. + if store.n_items() > groups.len() as u32 { + let len = groups.len() as u32; + store.splice(len, store.n_items() - len, &[] as &[BoxedAnyObject]); + } + + let count = ui.gallery.model.n_items(); + ui.gallery.subtitle.set_visible(count > 0); + // The noun tracks the active filter, so a Videos tab doesn't count "photos". + let (one, many) = match ui.gallery.kind.get() { + Some(PhotoKind::Video) => ("video", "videos"), + Some(PhotoKind::Raw) => ("raw photo", "raw photos"), + _ => ("photo", "photos"), + }; + ui.gallery.subtitle.set_label(&match count { + 1 => format!("1 {one}"), + n => format!("{n} {many}"), + }); +} + +pub(crate) fn group_photos(model: &gio::ListStore) -> Vec { + let mut groups: Vec = Vec::new(); + for i in 0..model.n_items() { + let Some(obj) = model.item(i) else { continue }; + let Some(boxed) = obj.downcast_ref::() else { + continue; + }; + let photo = boxed.borrow::().clone(); + let heading = day_heading(photo.capture_time); + match groups.last_mut() { + Some(group) if group.heading == heading => group.photos.push(photo), + _ => groups.push(PhotoGroup { + heading, + photos: vec![photo], + }), + } + } + groups +} + +/// Section heading for a capture time: "Today", "Yesterday", or the local date. +pub(crate) fn day_heading(secs: i64) -> String { + let Ok(date) = glib::DateTime::from_unix_local(secs) else { + return "Unknown date".into(); + }; + let same_day = |other: &glib::DateTime| { + other.year() == date.year() + && other.month() == date.month() + && other.day_of_month() == date.day_of_month() + }; + if let Ok(now) = glib::DateTime::now_local() { + if same_day(&now) { + return "Today".into(); + } + if let Ok(yesterday) = glib::DateTime::from_unix_local(now.to_unix() - 86_400) + && same_day(&yesterday) + { + return "Yesterday".into(); + } + } + date.format("%-d %B %Y") + .map(|s| s.to_string()) + .unwrap_or_else(|_| "Unknown date".into()) +} + +pub(crate) 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); + } + } + None +} + +pub(crate) fn format_capture_time(secs: i64) -> String { + let date = glib::DateTime::from_unix_local(secs); + match date { + Ok(d) => match d.format("%Y-%m-%d %H:%M:%S") { + Ok(s) => s.to_string(), + Err(_) => "Unknown Date".to_string(), + }, + Err(_) => "Unknown Date".to_string(), + } +} + +/// The capture time as a tile caption: the clock time alone, since the day is +/// already the section heading right above it. +pub(crate) fn short_capture_time(secs: i64) -> String { + glib::DateTime::from_unix_local(secs) + .and_then(|d| d.format("%H:%M")) + .map(|s| s.to_string()) + .unwrap_or_default() +} + +/// Fetch a timeline page from the daemon. When `append` is false the model is +/// cleared first (fresh load); otherwise the next page is tacked on. +pub(crate) fn load_gallery(ui: &Rc, append: bool) { + if ui.gallery.loading.get() { + return; + } + if !append { + // Fresh load: clear the timeline and show Loading until the first page lands. + ui.gallery.model.remove_all(); + gallery_status( + ui, + "image-x-generic-symbolic", + "Loading photos…", + "Reading your Proton Drive timeline.", + false, + ); + // Rebuild the date jump for the current kind, but only for a full-span + // load — a jump *to* a month sets a range and reloads, and refreshing the + // dropdown then would fight the selection the user just made. + if ui.gallery.range.get().is_none() { + refresh_photo_months(ui); + } + } + let offset = ui.gallery.model.n_items() as usize; + ui.gallery.loading.set(true); + ui.gallery.more.set_sensitive(false); + + ui.busy_begin(); + let rx = spawn_request( + ui.dirs.control_socket(), + Request::PhotosTimeline { + offset, + limit: PHOTOS_PAGE, + kind: ui.gallery.kind.get(), + range: ui.gallery.range.get(), + }, + ); + let ui = ui.clone(); + glib::spawn_future_local(async move { + let result = rx.recv().await; + ui.busy_end(); + ui.gallery.loading.set(false); + ui.gallery.more.set_sensitive(true); + match result { + Ok(Ok(Response::Photos { + available, + items, + counts, + })) => { + if !available { + gallery_status( + &ui, + "image-missing-symbolic", + "No photo library", + "This Proton account doesn't have Photos enabled.", + false, + ); + return; + } + // Label the filter tabs with live per-kind counts. + if let Some(counts) = counts { + update_gallery_tabs(&ui, counts); + } + // Take the daemon's word on ratios and thumbnail verdicts before + // anything is laid out: it persists both, so the very first frame + // justifies its rows correctly instead of guessing RATIO_UNKNOWN + // and reflowing as the images land. + { + let mut ratios = ui.gallery.photo_ratio.borrow_mut(); + let mut nothumb = ui.gallery.photo_nothumb.borrow_mut(); + for item in &items { + if let Some(ratio) = item.ratio { + ratios.insert(item.uid.clone(), ratio); + } + if item.no_thumb { + nothumb.insert(item.uid.clone()); + } + } + } + for item in &items { + ui.gallery.model.append(&BoxedAnyObject::new(item.clone())); + } + repaint_gallery(&ui); + if ui.gallery.model.n_items() == 0 { + gallery_status( + &ui, + "image-x-generic-symbolic", + "No photos yet", + "Photos you upload to Proton Drive appear here.", + false, + ); + return; + } + ui.gallery.content.set_visible_child_name("timeline"); + // Offer "Load more" only when the page came back full. + ui.gallery.more.set_visible(items.len() == PHOTOS_PAGE); + } + // A failed *next* page keeps the photos already on screen — the failure + // goes to a toast rather than wiping the timeline for a status page. + Ok(Ok(Response::Error { message, .. })) if append => { + toast_error(&ui, "Couldn't load more photos", &message) + } + Ok(Ok(Response::Error { message, .. })) => gallery_status( + &ui, + "dialog-warning-symbolic", + "Couldn't load photos", + &message, + false, + ), + Ok(Ok(_)) => gallery_status( + &ui, + "dialog-warning-symbolic", + "Couldn't load photos", + "Unexpected reply from the mount service.", + false, + ), + Ok(Err(_)) | Err(_) if append => toast_error( + &ui, + "Couldn't load more photos", + "The mount service didn't respond.", + ), + Ok(Err(_)) | Err(_) => gallery_unreachable(&ui), + } + }); +} + +/// Swap the Photos content area to the status page, hiding the pager. Retry is +/// offered only when restarting the mount service could actually fix it. +pub(crate) fn gallery_status(ui: &Rc, icon: &str, title: &str, description: &str, retry: bool) { + ui.gallery.status.set_icon_name(Some(icon)); + ui.gallery.status.set_title(title); + ui.gallery.status.set_description(Some(description)); + ui.gallery.retry.set_visible(retry); + ui.gallery.more.set_visible(false); + ui.gallery.content.set_visible_child_name("status"); +} + +/// Photos counterpart of [`browser_unreachable`]: auto-retry while the mount is +/// still starting, surface an actionable error + Retry once it's actually down. +pub(crate) fn gallery_unreachable(ui: &Rc) { + if service::is_failed() || !service::is_active() { + gallery_status( + ui, + "network-offline-symbolic", + "Not connected", + "The Proton Drive mount service isn't running.", + true, + ); + return; + } + gallery_status( + ui, + "folder-remote-symbolic", + "Connecting…", + "Waiting for the Proton Drive mount service to come up.", + false, + ); + let ui = ui.clone(); + glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || { + if ui.stack.visible_child_name().as_deref() == Some("gallery") { + load_gallery(&ui, false); + } + }); +} + +/// Play a video with an external player. Prefers `mpv` — it sniffs the container +/// from the bytes, so the cache's extensionless blob plays fine, and it is the +/// right tool for the HEVC `.mkv`s this is aimed at — and falls back to the +/// user's default handler when mpv isn't installed. +pub(crate) fn play_external(path: &str) { + if Command::new("mpv").arg(path).spawn().is_ok() { + return; + } + open_path(path); +} + +/// Download a Photos-library video, then hand it to an external player. Unlike a +/// still photo — which the in-app lightbox can render — a video needs a real +/// player, and the photos volume isn't part of the FUSE mount, so there is no +/// path to stream it from: [`Request::OpenPhoto`] fetches the whole file into the +/// cache (served straight from there on a repeat) and we launch the player on it. +/// +/// For large videos kept in an on-demand *drive* folder, streaming through the +/// mount is the better route — that is the file-browser "Play" action, not this. +pub(crate) fn play_video(ui: &Rc, uid: String) { + toast(ui, "Preparing video…"); + let rx = spawn_request(ui.dirs.control_socket(), Request::OpenPhoto { uid }); + let ui = ui.clone(); + glib::spawn_future_local(async move { + match rx.recv().await { + Ok(Ok(Response::FilePath { path })) => play_external(&path), + Ok(Ok(Response::Error { message, kind })) => { + toast_failure(&ui, "Couldn't open this video", &message, kind) + } + Ok(Ok(_)) => toast_error( + &ui, + "Couldn't open this video", + "Unexpected reply from the mount service.", + ), + Ok(Err(_)) | Err(_) => { + toast_error(&ui, "Couldn't open this video", "Couldn't reach Proton Drive.") + } + } + }); +} diff --git a/crates/pdfs-gui/src/app/pages/shared.rs b/crates/pdfs-gui/src/app/pages/shared.rs new file mode 100644 index 0000000..984297f --- /dev/null +++ b/crates/pdfs-gui/src/app/pages/shared.rs @@ -0,0 +1,492 @@ +use crate::*; + +pub(crate) struct SharedState { + // Shared page. Three sections rebuilt wholesale on each load: shared-with-me, + // incoming invitations, and bookmarks. Rows are tracked so a reload can remove + // the old ones before appending the new (adw groups have no clear-all). + pub(crate) content: gtk4::Stack, + pub(crate) status: adw::StatusPage, + pub(crate) retry: gtk4::Button, + pub(crate) with_me_group: adw::PreferencesGroup, + pub(crate) invitations_group: adw::PreferencesGroup, + pub(crate) bookmarks_group: adw::PreferencesGroup, + pub(crate) rows: RefCell>, + /// Guards the Shared page's load so overlapping navigations don't stack. + pub(crate) inflight: Cell, + /// When the Shared page last painted good data. `None` = never / invalidated, + /// forcing a fetch on next visit. See [`PAGE_TTL`]. + pub(crate) loaded_at: Cell>, +} + +/// Widgets the Shared page's load/repaint touch. +pub(crate) struct SharedWidgets { + pub(crate) content: gtk4::Stack, + pub(crate) status: adw::StatusPage, + pub(crate) shared_with_me: adw::PreferencesGroup, + pub(crate) invitations: adw::PreferencesGroup, + pub(crate) bookmarks: adw::PreferencesGroup, + pub(crate) retry: gtk4::Button, + pub(crate) add_bookmark: gtk4::Button, + pub(crate) refresh: gtk4::Button, +} + +/// The Shared page: three stacked sections — items shared *with* me (each with a +/// Leave action), invitations addressed to me pending accept/reject, and public +/// links I've bookmarked (open / remove). All three live outside the mount tree, +/// so the page addresses them by uid/id/token and always re-lists from the daemon. +pub(crate) fn build_shared_page() -> (gtk4::Widget, SharedWidgets) { + let title = gtk4::Label::builder() + .label("Shared with me") + .halign(gtk4::Align::Start) + .build(); + title.add_css_class("title-2"); + let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 2); + titles.set_hexpand(true); + titles.append(&title); + + let add_bookmark = gtk4::Button::builder() + .label("Add Bookmark") + .valign(gtk4::Align::Center) + .build(); + add_bookmark.add_css_class("flat"); + let refresh = refresh_button(); + + let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + header.append(&titles); + header.append(&refresh); + header.append(&add_bookmark); + + let shared_with_me = adw::PreferencesGroup::builder() + .title("Shared with me") + .build(); + let invitations = adw::PreferencesGroup::builder() + .title("Invitations") + .build(); + let bookmarks = adw::PreferencesGroup::builder().title("Bookmarks").build(); + + let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18); + groups.append(&shared_with_me); + groups.append(&invitations); + groups.append(&bookmarks); + let clamp = adw::Clamp::builder().child(&groups).build(); + let scroll = gtk4::ScrolledWindow::builder() + .vexpand(true) + .child(&clamp) + .build(); + + let retry = gtk4::Button::builder() + .label("Retry") + .halign(gtk4::Align::Center) + .build(); + retry.add_css_class("pill"); + retry.add_css_class("suggested-action"); + retry.set_visible(false); + let status = adw::StatusPage::builder() + .icon_name("emblem-shared-symbolic") + .vexpand(true) + .child(&retry) + .build(); + status.add_css_class("compact"); + + let content = gtk4::Stack::new(); + content.set_vexpand(true); + content.set_transition_type(gtk4::StackTransitionType::Crossfade); + content.add_named(&scroll, Some("list")); + content.add_named(&status, Some("status")); + + let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12); + inner.set_margin_top(12); + inner.set_margin_bottom(12); + inner.set_margin_start(12); + inner.set_margin_end(12); + inner.append(&header); + inner.append(&content); + + ( + inner.upcast(), + SharedWidgets { + content, + status, + shared_with_me, + invitations, + bookmarks, + retry, + add_bookmark, + refresh, + }, + ) +} + +/// Install the Shared page's retry and Add-Bookmark buttons. +pub(crate) fn wire_shared(ui: &Rc, retry: >k4::Button, add_bookmark: >k4::Button) { + let ui_retry = ui.clone(); + retry.connect_clicked(move |_| { + service::restart(); + load_shared(&ui_retry); + }); + let ui_add = ui.clone(); + add_bookmark.connect_clicked(move |_| prompt_add_bookmark(&ui_add)); +} + +/// Show a status page in place of the Shared sections. +pub(crate) fn shared_status(ui: &Rc, icon: &str, title: &str, description: &str, retry: bool) { + ui.shared.status.set_icon_name(Some(icon)); + ui.shared.status.set_title(title); + ui.shared.status.set_description(Some(description)); + ui.shared.retry.set_visible(retry); + ui.shared.content.set_visible_child_name("status"); +} + +/// Fetch the three Shared sections (shared-with-me, invitations, bookmarks) in +/// parallel and repaint the page once all three land. +pub(crate) fn load_shared(ui: &Rc) { + if ui.shared.inflight.get() { + return; + } + ui.shared.inflight.set(true); + shared_status( + ui, + "emblem-shared-symbolic", + "Loading…", + "Reading your shared items.", + false, + ); + + ui.busy_begin(); + let socket = ui.dirs.control_socket(); + let shared_rx = spawn_request(socket.clone(), Request::ListSharedWithMe); + let invites_rx = spawn_request(socket.clone(), Request::ListInvitations); + let bookmarks_rx = spawn_request(socket, Request::ListBookmarks); + let ui = ui.clone(); + glib::spawn_future_local(async move { + let shared = shared_rx.recv().await; + let invites = invites_rx.recv().await; + let bookmarks = bookmarks_rx.recv().await; + ui.busy_end(); + ui.shared.inflight.set(false); + + // A transport failure on any of the three means the daemon isn't up. + if matches!(shared, Ok(Err(_)) | Err(_)) + || matches!(invites, Ok(Err(_)) | Err(_)) + || matches!(bookmarks, Ok(Err(_)) | Err(_)) + { + ui.shared.loaded_at.set(None); + shared_unreachable(&ui); + return; + } + + let shared_items = match shared { + Ok(Ok(Response::Entries { entries })) => entries, + _ => Vec::new(), + }; + let invitations = match invites { + Ok(Ok(Response::Invitations { items })) => items, + _ => Vec::new(), + }; + let bookmark_items = match bookmarks { + Ok(Ok(Response::Bookmarks { items })) => items, + _ => Vec::new(), + }; + repaint_shared(&ui, &shared_items, &invitations, &bookmark_items); + ui.shared.loaded_at.set(Some(Instant::now())); + }); +} + +/// The daemon didn't answer the Shared page. Same still-starting vs. down split +/// as the other pages. +pub(crate) fn shared_unreachable(ui: &Rc) { + if service::is_failed() || !service::is_active() { + shared_status( + ui, + "network-offline-symbolic", + "Not connected", + "The Proton Drive mount service isn't running.", + true, + ); + return; + } + shared_status( + ui, + "folder-remote-symbolic", + "Connecting…", + "Waiting for the Proton Drive mount service to come up.", + false, + ); + let ui = ui.clone(); + glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || { + if ui.stack.visible_child_name().as_deref() == Some("shared") { + load_shared(&ui); + } + }); +} + +/// Rebuild the three Shared sections. Rows added last time are removed first — +/// adw groups have no clear-all — then each section is repopulated, showing a +/// dim placeholder row when empty. +pub(crate) fn repaint_shared( + ui: &Rc, + shared: &[DirEntry], + invitations: &[InvitationInfo], + bookmarks: &[BookmarkInfo], +) { + for (group, row) in ui.shared.rows.borrow_mut().drain(..) { + group.remove(&row); + } + ui.shared.content.set_visible_child_name("list"); + let mut rows: Vec<(adw::PreferencesGroup, gtk4::Widget)> = Vec::new(); + + // Shared with me: name + Leave. + if shared.is_empty() { + let row = dim_row("Nothing is shared with you."); + ui.shared.with_me_group.add(&row); + rows.push((ui.shared.with_me_group.clone(), row.upcast())); + } else { + for entry in shared { + let row = adw::ActionRow::builder().title(&entry.name).build(); + row.add_prefix(>k4::Image::from_icon_name(if entry.is_dir { + "folder-symbolic" + } else { + "text-x-generic-symbolic" + })); + let leave = gtk4::Button::builder() + .label("Leave") + .valign(gtk4::Align::Center) + .build(); + leave.add_css_class("flat"); + let ui_leave = ui.clone(); + let uid = entry.uid.clone(); + let name = entry.name.clone(); + leave.connect_clicked(move |_| prompt_leave_shared(&ui_leave, &uid, &name)); + row.add_suffix(&leave); + ui.shared.with_me_group.add(&row); + rows.push((ui.shared.with_me_group.clone(), row.upcast())); + } + } + + // Invitations: inviter + item, Accept / Reject. + if invitations.is_empty() { + let row = dim_row("No pending invitations."); + ui.shared.invitations_group.add(&row); + rows.push((ui.shared.invitations_group.clone(), row.upcast())); + } else { + for inv in invitations { + let item = inv + .name + .clone() + .unwrap_or_else(|| "a shared item".to_string()); + let row = adw::ActionRow::builder() + .title(&item) + .subtitle(format!("from {}", inv.inviter_email)) + .build(); + row.add_prefix(>k4::Image::from_icon_name(if inv.is_dir { + "folder-symbolic" + } else { + "text-x-generic-symbolic" + })); + let reject = gtk4::Button::builder() + .icon_name("window-close-symbolic") + .tooltip_text("Reject") + .valign(gtk4::Align::Center) + .build(); + reject.add_css_class("flat"); + let accept = gtk4::Button::builder() + .label("Accept") + .valign(gtk4::Align::Center) + .build(); + accept.add_css_class("suggested-action"); + let ui_acc = ui.clone(); + let id_acc = inv.id.clone(); + accept.connect_clicked(move |_| { + respond_invitation(&ui_acc, &id_acc, true); + }); + let ui_rej = ui.clone(); + let id_rej = inv.id.clone(); + reject.connect_clicked(move |_| { + respond_invitation(&ui_rej, &id_rej, false); + }); + row.add_suffix(&accept); + row.add_suffix(&reject); + ui.shared.invitations_group.add(&row); + rows.push((ui.shared.invitations_group.clone(), row.upcast())); + } + } + + // Bookmarks: name/URL, Open / Remove. + if bookmarks.is_empty() { + let row = dim_row("No saved bookmarks."); + ui.shared.bookmarks_group.add(&row); + rows.push((ui.shared.bookmarks_group.clone(), row.upcast())); + } else { + for bm in bookmarks { + let title = bm.name.clone().unwrap_or_else(|| "Shared link".to_string()); + let row = adw::ActionRow::builder() + .title(&title) + .subtitle(&bm.url) + .build(); + row.add_prefix(>k4::Image::from_icon_name("emblem-symbolic-link")); + let remove = gtk4::Button::builder() + .icon_name("user-trash-symbolic") + .tooltip_text("Remove bookmark") + .valign(gtk4::Align::Center) + .build(); + remove.add_css_class("flat"); + let open = gtk4::Button::builder() + .icon_name("external-link-symbolic") + .tooltip_text("Open in browser") + .valign(gtk4::Align::Center) + .build(); + open.add_css_class("flat"); + let url_open = bm.url.clone(); + open.connect_clicked(move |_| open_uri(&url_open)); + let ui_rm = ui.clone(); + let token = bm.token.clone(); + let name_rm = title.clone(); + remove.connect_clicked(move |_| prompt_remove_bookmark(&ui_rm, &token, &name_rm)); + row.add_suffix(&open); + row.add_suffix(&remove); + ui.shared.bookmarks_group.add(&row); + rows.push((ui.shared.bookmarks_group.clone(), row.upcast())); + } + } + + *ui.shared.rows.borrow_mut() = rows; +} + +/// Accept or reject an invitation, then reload the Shared page. +pub(crate) fn respond_invitation(ui: &Rc, id: &str, accept: bool) { + let req = if accept { + Request::AcceptInvitation { id: id.to_string() } + } else { + Request::RejectInvitation { id: id.to_string() } + }; + let (done, failed) = if accept { + ("Invitation accepted", "Couldn't accept the invitation") + } else { + ("Invitation rejected", "Couldn't reject the invitation") + }; + run_shared_mutation(ui, req, done, failed); +} + +/// Confirm, then leave a node shared with me. +pub(crate) fn prompt_leave_shared(ui: &Rc, uid: &str, name: &str) { + let win = ui_window(ui); + let dialog = adw::AlertDialog::builder() + .heading("Leave shared item") + .body(format!( + "Leave “{name}”? You'll lose access until you're invited again." + )) + .build(); + dialog.add_response("cancel", "Cancel"); + dialog.add_response("leave", "Leave"); + dialog.set_response_appearance("leave", adw::ResponseAppearance::Destructive); + dialog.set_default_response(Some("cancel")); + dialog.set_close_response("cancel"); + let ui = ui.clone(); + let uid = uid.to_string(); + dialog.connect_response(None, move |_, resp| { + if resp == "leave" { + run_shared_mutation( + &ui, + Request::LeaveShared { uid: uid.clone() }, + "Left shared item", + "Couldn't leave the shared item", + ); + } + }); + dialog.present(win.as_ref()); +} + +/// Confirm, then remove a saved bookmark. +pub(crate) fn prompt_remove_bookmark(ui: &Rc, token: &str, name: &str) { + let win = ui_window(ui); + let dialog = adw::AlertDialog::builder() + .heading("Remove bookmark") + .body(format!("Remove the bookmark for “{name}”?")) + .build(); + dialog.add_response("cancel", "Cancel"); + dialog.add_response("remove", "Remove"); + dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive); + dialog.set_default_response(Some("cancel")); + dialog.set_close_response("cancel"); + let ui = ui.clone(); + let token = token.to_string(); + dialog.connect_response(None, move |_, resp| { + if resp == "remove" { + run_shared_mutation( + &ui, + Request::DeleteBookmark { + token: token.clone(), + }, + "Bookmark removed", + "Couldn't remove the bookmark", + ); + } + }); + dialog.present(win.as_ref()); +} + +/// Prompt for a public-link URL (and optional password) and save it as a bookmark. +pub(crate) fn prompt_add_bookmark(ui: &Rc) { + let win = ui_window(ui); + let dialog = adw::AlertDialog::builder() + .heading("Add bookmark") + .body("Paste a Proton Drive public link to save it here.") + .build(); + let group = adw::PreferencesGroup::new(); + let url_row = adw::EntryRow::builder() + .title("Public link URL") + .activates_default(true) + .build(); + let pw_row = adw::PasswordEntryRow::builder() + .title("Password (if the link has one)") + .activates_default(true) + .build(); + group.add(&url_row); + group.add(&pw_row); + dialog.set_extra_child(Some(&group)); + dialog.add_response("cancel", "Cancel"); + dialog.add_response("add", "Add"); + dialog.set_response_appearance("add", adw::ResponseAppearance::Suggested); + dialog.set_default_response(Some("add")); + dialog.set_close_response("cancel"); + let ui = ui.clone(); + dialog.connect_response(None, move |_, resp| { + if resp != "add" { + return; + } + let url = url_row.text().trim().to_string(); + if url.is_empty() { + toast_error(&ui, "Couldn't add bookmark", "A URL is required."); + return; + } + let pw = pw_row.text().to_string(); + let password = if pw.is_empty() { None } else { Some(pw) }; + run_shared_mutation( + &ui, + Request::CreateBookmark { url, password }, + "Bookmark saved", + "Couldn't add the bookmark", + ); + }); + dialog.present(win.as_ref()); +} + +/// Run a mutation raised from the Shared page and reload the page on success. +pub(crate) fn run_shared_mutation(ui: &Rc, req: Request, done: &'static str, failed: &'static str) { + ui.busy_begin(); + let rx = spawn_request(ui.dirs.control_socket(), req); + let ui = ui.clone(); + let done = done.to_string(); + glib::spawn_future_local(async move { + let result = rx.recv().await; + ui.busy_end(); + match result { + Ok(Ok(Response::Ok { .. })) => { + load_shared(&ui); + toast(&ui, &done); + } + Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, failed, &message, kind), + _ => toast_error(&ui, failed, "The mount service didn't respond."), + } + }); +} diff --git a/crates/pdfs-gui/src/app/pages/shared_by_me.rs b/crates/pdfs-gui/src/app/pages/shared_by_me.rs new file mode 100644 index 0000000..48d0a50 --- /dev/null +++ b/crates/pdfs-gui/src/app/pages/shared_by_me.rs @@ -0,0 +1,293 @@ +use crate::*; + +pub(crate) struct SharedByMeState { + // Shared (by me) page: one section listing the items I've shared, each with a + // copy-link / manage affordance. Rebuilt wholesale on each load. + pub(crate) content: gtk4::Stack, + pub(crate) status: adw::StatusPage, + pub(crate) retry: gtk4::Button, + pub(crate) group: adw::PreferencesGroup, + pub(crate) rows: RefCell>, + pub(crate) inflight: Cell, + pub(crate) loaded_at: Cell>, +} + +/// Widgets the Shared (by-me) page's load/repaint touch. +pub(crate) struct SharedByMeWidgets { + pub(crate) content: gtk4::Stack, + pub(crate) status: adw::StatusPage, + pub(crate) group: adw::PreferencesGroup, + pub(crate) retry: gtk4::Button, + pub(crate) refresh: gtk4::Button, +} + +/// The Shared page: one section listing the items I have shared with others — +/// each row summarizing who has access and carrying its public link (copy/open) +/// and a Manage shortcut into the per-node Share dialog. +pub(crate) fn build_shared_by_me_page() -> (gtk4::Widget, SharedByMeWidgets) { + let title = gtk4::Label::builder() + .label("Shared") + .halign(gtk4::Align::Start) + .hexpand(true) + .build(); + title.add_css_class("title-2"); + let refresh = refresh_button(); + let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + header.set_hexpand(true); + header.append(&title); + header.append(&refresh); + + let group = adw::PreferencesGroup::new(); + let clamp = adw::Clamp::builder().child(&group).build(); + let scroll = gtk4::ScrolledWindow::builder() + .vexpand(true) + .child(&clamp) + .build(); + + let retry = gtk4::Button::builder() + .label("Retry") + .halign(gtk4::Align::Center) + .build(); + retry.add_css_class("pill"); + retry.add_css_class("suggested-action"); + retry.set_visible(false); + let status = adw::StatusPage::builder() + .icon_name("emblem-shared-symbolic") + .vexpand(true) + .child(&retry) + .build(); + status.add_css_class("compact"); + + let content = gtk4::Stack::new(); + content.set_vexpand(true); + content.set_transition_type(gtk4::StackTransitionType::Crossfade); + content.add_named(&scroll, Some("list")); + content.add_named(&status, Some("status")); + + let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12); + inner.set_margin_top(12); + inner.set_margin_bottom(12); + inner.set_margin_start(12); + inner.set_margin_end(12); + inner.append(&header); + inner.append(&content); + + ( + inner.upcast(), + SharedByMeWidgets { + content, + status, + group, + retry, + refresh, + }, + ) +} + +/// Install the Shared (by-me) page's retry button. +pub(crate) fn wire_shared_by_me(ui: &Rc, retry: >k4::Button) { + let ui_retry = ui.clone(); + retry.connect_clicked(move |_| { + service::restart(); + load_shared_by_me(&ui_retry); + }); +} + +/// Show a status page in place of the Shared (by-me) list. +pub(crate) fn shared_by_me_status(ui: &Rc, icon: &str, title: &str, description: &str, retry: bool) { + ui.shared_by_me.status.set_icon_name(Some(icon)); + ui.shared_by_me.status.set_title(title); + ui.shared_by_me.status.set_description(Some(description)); + ui.shared_by_me.retry.set_visible(retry); + ui.shared_by_me.content.set_visible_child_name("status"); +} + +/// Fetch the shared-by-me listing and repaint the page. +pub(crate) fn load_shared_by_me(ui: &Rc) { + if ui.shared_by_me.inflight.get() { + return; + } + ui.shared_by_me.inflight.set(true); + shared_by_me_status( + ui, + "emblem-shared-symbolic", + "Loading…", + "Reading what you've shared.", + false, + ); + ui.busy_begin(); + let rx = spawn_request(ui.dirs.control_socket(), Request::ListSharedByMe); + let ui = ui.clone(); + glib::spawn_future_local(async move { + let result = rx.recv().await; + ui.busy_end(); + ui.shared_by_me.inflight.set(false); + match result { + Ok(Ok(Response::SharedByMe { items })) => { + repaint_shared_by_me(&ui, &items); + ui.shared_by_me.loaded_at.set(Some(Instant::now())); + } + Ok(Ok(Response::Error { message, .. })) => shared_by_me_status( + &ui, + "dialog-warning-symbolic", + "Couldn't read your shares", + &message, + false, + ), + Ok(Ok(_)) => shared_by_me_status( + &ui, + "dialog-warning-symbolic", + "Couldn't read your shares", + "Unexpected reply from the mount service.", + false, + ), + Ok(Err(_)) | Err(_) => { + ui.shared_by_me.loaded_at.set(None); + shared_by_me_unreachable(&ui); + } + } + }); +} + +/// The daemon didn't answer the Shared (by-me) page. +pub(crate) fn shared_by_me_unreachable(ui: &Rc) { + if service::is_failed() || !service::is_active() { + shared_by_me_status( + ui, + "network-offline-symbolic", + "Not connected", + "The Proton Drive mount service isn't running.", + true, + ); + return; + } + shared_by_me_status( + ui, + "folder-remote-symbolic", + "Connecting…", + "Waiting for the Proton Drive mount service to come up.", + false, + ); + let ui = ui.clone(); + glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || { + if ui.stack.visible_child_name().as_deref() == Some("sharedbyme") { + load_shared_by_me(&ui); + } + }); +} + +/// Rebuild the Shared (by-me) section from a fresh listing. +pub(crate) fn repaint_shared_by_me(ui: &Rc, items: &[SharedItem]) { + for row in ui.shared_by_me.rows.borrow_mut().drain(..) { + ui.shared_by_me.group.remove(&row); + } + if items.is_empty() { + shared_by_me_status( + ui, + "emblem-shared-symbolic", + "Nothing shared yet", + "Items you share with people or by link show up here.", + false, + ); + return; + } + ui.shared_by_me.content.set_visible_child_name("list"); + let mut rows: Vec = Vec::new(); + for item in items { + let row = adw::ActionRow::builder() + .title(&item.name) + .subtitle(shared_item_summary(item)) + .build(); + row.add_prefix(>k4::Image::from_icon_name(if item.is_dir { + "folder-symbolic" + } else { + "text-x-generic-symbolic" + })); + + // Copy / open the public link, when there is one with a recovered URL. + if let Some(url) = item.link.as_ref().and_then(|l| l.url.clone()) { + let copy = gtk4::Button::builder() + .icon_name("edit-copy-symbolic") + .tooltip_text("Copy link") + .valign(gtk4::Align::Center) + .build(); + copy.add_css_class("flat"); + let ui_copy = ui.clone(); + let url_copy = url.clone(); + copy.connect_clicked(move |btn| { + btn.clipboard().set_text(&url_copy); + toast(&ui_copy, "Link copied"); + }); + let open = gtk4::Button::builder() + .icon_name("external-link-symbolic") + .tooltip_text("Open link") + .valign(gtk4::Align::Center) + .build(); + open.add_css_class("flat"); + let url_open = url.clone(); + open.connect_clicked(move |_| open_uri(&url_open)); + row.add_suffix(©); + row.add_suffix(&open); + } + + // Manage opens the per-node Share dialog — only when the node's path is + // known (it has been browsed to this session); the dialog is path-keyed. + if !item.path.is_empty() { + let manage = gtk4::Button::builder() + .label("Manage") + .valign(gtk4::Align::Center) + .build(); + manage.add_css_class("flat"); + let ui_manage = ui.clone(); + let entry = shared_item_as_entry(item); + manage.connect_clicked(move |_| open_share_dialog(&ui_manage, &entry)); + row.add_suffix(&manage); + } + + ui.shared_by_me.group.add(&row); + rows.push(row.upcast()); + } + *ui.shared_by_me.rows.borrow_mut() = rows; +} + +/// A one-line summary of who can reach a shared item, for its row subtitle. +pub(crate) fn shared_item_summary(item: &SharedItem) -> String { + let mut parts = Vec::new(); + if item.member_count > 0 { + parts.push(format!( + "{} {}", + item.member_count, + if item.member_count == 1 { + "person" + } else { + "people" + } + )); + } + if item.invite_count > 0 { + parts.push(format!("{} pending", item.invite_count)); + } + if item.link.is_some() { + parts.push("Public link".to_string()); + } + if parts.is_empty() { + "Shared".to_string() + } else { + parts.join(" · ") + } +} + +/// Build a [`DirEntry`] from a [`SharedItem`] so the path-keyed Share dialog can +/// open on it. Only used when the item's path is known. +pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry { + DirEntry { + name: item.name.clone(), + is_dir: item.is_dir, + size: 0, + modified: 0, + pinned: false, + cached: false, + uid: item.uid.clone(), + path: item.path.clone(), + } +} diff --git a/crates/pdfs-gui/src/app/pages/status.rs b/crates/pdfs-gui/src/app/pages/status.rs new file mode 100644 index 0000000..a821a18 --- /dev/null +++ b/crates/pdfs-gui/src/app/pages/status.rs @@ -0,0 +1,787 @@ +use crate::*; + +pub(crate) struct StatusState { + /// Whether a [`Request::Status`] round-trip is already in flight, so the 2s + /// refresh tick doesn't pile worker threads up on a slow/wedged daemon. + pub(crate) status_inflight: Cell, + /// Guards the [`Request::GetQueueStatus`] poll the same way: at most one + /// in-flight at a time so a wedged daemon can't stack worker threads. + pub(crate) transfers_inflight: Cell, + /// Activity group + its current rows, hidden when no transfer is in flight. + pub(crate) transfers_group: adw::PreferencesGroup, + pub(crate) transfer_rows: RefCell>, + // Main page. + pub(crate) account_row: adw::ActionRow, + /// Read-only mount status line. The mount is driven by the systemd user + /// service (enabled on login), not by the user — so this only reports. + pub(crate) mount_row: adw::ActionRow, + pub(crate) cache_bar: gtk4::ProgressBar, + pub(crate) 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. + pub(crate) autostart_row: adw::SwitchRow, + /// Cache-budget editor (GiB). Populated once from config; user edits drive a + /// `SetCacheBudget` round-trip. Guarded by [`Self::settings_suppress`]. + pub(crate) budget_row: adw::SpinRow, + /// Shows the active mountpoint in its subtitle; updated when the user picks a + /// new folder. + pub(crate) mountpoint_row: adw::ActionRow, + /// Set while a settings widget is being populated programmatically, so its + /// change handler skips the IPC/systemd side effect. + pub(crate) settings_suppress: Cell, + pub(crate) 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. + pub(crate) pin_rows: RefCell>, + /// The pin paths last rendered, the diff baseline for [`repaint_pins`]. + /// `None` = nothing built yet; `Some(empty)` = the placeholder is shown. + pub(crate) pins_state: RefCell>>, + /// The mount state the *last* desktop notification reported, so a flap only + /// notifies on the edge. `None` until the first status reply, so a cold start + /// doesn't announce "disconnected" before the service has had a chance to come + /// up. + pub(crate) notified_mounted: Cell>, + /// How many transfers were in flight on the previous poll. A drop to zero is + /// what "sync complete" means; there's no completion event on the wire. + pub(crate) active_transfers: Cell, +} + +/// One rendered pin row, retained so [`repaint_pins`] can flip the unpin button's +/// `sensitive` in place (when the pin set is unchanged) instead of rebuilding. +pub(crate) struct PinRow { + pub(crate) row: adw::ActionRow, + /// The unpin button, absent on the placeholder row. + pub(crate) unpin: Option, +} + +/// One rendered row in the Activity group: a description over a progress bar. +/// Retained so [`repaint_transfers`] can update the bar and label in place each +/// tick when the active set is unchanged, instead of rebuilding. +pub(crate) struct TransferRow { + pub(crate) row: adw::PreferencesRow, + pub(crate) label: gtk4::Label, + pub(crate) bar: gtk4::ProgressBar, +} + +/// What one Activity row should say this tick, and how far along it is — +/// `None` meaning "no total known", which the bar shows by pulsing. Jobs and +/// transfers both render to this, so the group is one list in the order the +/// daemon reports: the jobs that frame the work, then the files moving under it. +pub(crate) struct ActivityLine { + pub(crate) text: String, + pub(crate) fraction: Option, +} + +/// Widgets the settings page hands back for the refresh loop and action wiring. +pub(crate) struct MainWidgets { + pub(crate) account_row: adw::ActionRow, + pub(crate) mount_row: adw::ActionRow, + /// Live upload/download progress, populated by the refresh loop. + pub(crate) transfers_group: adw::PreferencesGroup, + pub(crate) cache_bar: gtk4::ProgressBar, + pub(crate) cache_label: gtk4::Label, + pub(crate) pins_group: adw::PreferencesGroup, + pub(crate) logout_button: gtk4::Button, + /// "Start on login" toggle, reflecting the systemd unit's enabled state. + pub(crate) autostart_row: adw::SwitchRow, + /// Cache soft-cap editor, in GiB; `0` = unlimited. + pub(crate) budget_row: adw::SpinRow, + /// Purges all unpinned cached content. + pub(crate) purge_button: gtk4::Button, + /// Shows the active mountpoint; its suffix button opens a folder chooser. + pub(crate) mountpoint_row: adw::ActionRow, + pub(crate) 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. +pub(crate) 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(); + let avatar = adw::Avatar::new(40, None, true); + account_row.add_prefix(&avatar); + let logout_button = gtk4::Button::builder() + .label("Sign out") + .valign(gtk4::Align::Center) + .build(); + logout_button.add_css_class("flat"); + account_row.add_suffix(&logout_button); + account_group.add(&account_row); + + // Mount group: a read-only status line. The mount is managed automatically + // by the systemd user service; there is no toggle to fiddle with. + let mount_group = adw::PreferencesGroup::builder().title("Drive").build(); + let mount_row = adw::ActionRow::builder() + .title("Proton Drive") + .subtitle("Not mounted") + .build(); + mount_group.add(&mount_row); + + // Activity group: live upload/download progress. Hidden until the refresh + // loop sees an in-flight transfer from `Request::GetQueueStatus`. + let transfers_group = adw::PreferencesGroup::builder() + .title("Activity") + .description("Files moving to and from Proton Drive.") + .visible(false) + .build(); + + // 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.") + .build(); + let storage_box = gtk4::Box::new(gtk4::Orientation::Vertical, 6); + storage_box.set_margin_top(6); + storage_box.set_margin_bottom(6); + let cache_bar = gtk4::ProgressBar::new(); + let cache_label = gtk4::Label::builder().halign(gtk4::Align::Start).build(); + cache_label.add_css_class("dim-label"); + storage_box.append(&cache_bar); + storage_box.append(&cache_label); + 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() + .title("Pinned files") + .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); + inner.set_margin_start(12); + inner.set_margin_end(12); + inner.append(&account_group); + inner.append(&mount_group); + inner.append(&transfers_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) + .child(&inner) + .build(); + let scroll = gtk4::ScrolledWindow::builder().child(&clamp).build(); + + ( + scroll.upcast(), + MainWidgets { + account_row, + mount_row, + transfers_group, + cache_bar, + cache_label, + pins_group, + logout_button, + autostart_row, + budget_row, + purge_button, + mountpoint_row, + mountpoint_button, + }, + ) +} + +/// Bytes per GiB, for the cache-budget editor's unit conversion. +pub(crate) 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. +pub(crate) 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.status.settings_suppress.set(true); + ui.status.budget_row + .set_value(config.resolved_cache_budget() as f64 / GIB); + ui.status.mountpoint_row + .set_subtitle(&ui.dirs.resolved_mountpoint(&config).display().to_string()); + ui.status.autostart_row.set_active(service::is_enabled()); + ui.status.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.status.budget_row.connect_value_notify(move |row| { + if ui_budget.status.settings_suppress.get() { + return; + } + let bytes = (row.value() * GIB).round() as u64; + settings_request( + &ui_budget, + Request::SetCacheBudget { bytes }, + "Cache budget updated", + "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, + "Cache purged", + "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.status.autostart_row.connect_active_notify(move |row| { + if ui_auto.status.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, +/// confirming with `done` or reporting the daemon's error under `failed`. Unlike +/// [`run_mutation`] there's no browser reload; the next refresh tick repaints the +/// cache read-out. +pub(crate) fn settings_request(ui: &Rc, req: Request, done: &'static str, failed: &'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 { .. })) => toast(&ui, done), + Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, failed, &message, kind), + _ => toast_error(&ui, failed, "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. +pub(crate) 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) { + toast_error(&ui, "Couldn't save mountpoint", &e.to_string()); + return; + } + ui.status.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. +pub(crate) fn wire_retry(ui: &Rc) { + let ui_browser = ui.clone(); + ui.browser.retry.clone().connect_clicked(move |_| { + service::restart(); + load_browser(&ui_browser); + }); + let ui_gallery = ui.clone(); + ui.gallery.retry.clone().connect_clicked(move |_| { + service::restart(); + load_gallery(&ui_gallery, false); + }); + let ui_trash = ui.clone(); + ui.trash.retry.clone().connect_clicked(move |_| { + service::restart(); + load_trash(&ui_trash); + }); +} + +/// Repaint the window from the cached login identity, then kick an async mount- +/// status fetch. Runs on the 2s tick: the identity check is instant (no keyring), +/// and the status round-trip is offloaded to a worker so the main loop never +/// blocks on a slow or wedged daemon. +pub(crate) fn refresh(ui: &Rc) { + // Login identity decides which page is shown. Read the cached session — set + // at startup and on login/logout — never the keyring. + { + let session = ui.session.borrow(); + match session.as_ref() { + Some(s) => { + // Only pull the user onto a destination when they're sitting on the + // login page; otherwise leave whichever page they navigated to. + if ui.stack.visible_child_name().as_deref() == Some("login") { + ui.stack.set_visible_child_name("browser"); + } + ui.nav.set_collapsed(false); + ui.status.account_row.set_title(&s.username); + ui.status.account_row.set_subtitle("Proton account"); + } + None => { + ui.stack.set_visible_child_name("login"); + // Collapsed + showing content = the login page owns the window and + // no destination is reachable without a session. + ui.nav.set_collapsed(true); + ui.nav.set_show_content(true); + return; + } + } + } + + refresh_status(ui); + refresh_transfers(ui); + // Both of these pages show work as it happens, so they follow the tick while + // they are on screen. Every other page loads on navigation only. + match ui.stack.visible_child_name().as_deref() { + Some("devices") => refresh_sync_folders(ui), + Some("activity") => refresh_activity(ui), + _ => {} + } +} + +/// Record the mount state seen by the last status poll: gate every control that +/// needs a live daemon, and notify the desktop when the state actually flips. +/// +/// The gating is the point — without it, New Folder / Upload / the details pane's +/// actions stay clickable while the mount is down and each click buys a round-trip +/// that can only fail. A greyed control says so up front. +pub(crate) fn set_mounted(ui: &Rc, mounted: bool) { + *ui.mounted.borrow_mut() = mounted; + ui.browser.new_folder.set_sensitive(mounted); + ui.browser.upload.set_sensitive(mounted); + ui.browser.upload_folder.set_sensitive(mounted); + ui.gallery.upload.set_sensitive(mounted); + ui.details.details.pin_row.set_sensitive(mounted); + ui.details.details.rename_button.set_sensitive(mounted); + ui.details.details.trash_button.set_sensitive(mounted); + ui.details.details.open_button.set_sensitive(mounted); + + // Only notify on a real edge, and never for the first reading: at startup the + // service is usually still coming up, and "disconnected" would be a lie. + if ui.status.notified_mounted.get() == Some(mounted) { + return; + } + let first = ui.status.notified_mounted.replace(Some(mounted)).is_none(); + if first { + return; + } + if mounted { + notify( + "mount-state", + "Proton Drive connected", + "Your Drive is mounted and available.", + ); + } else { + notify( + "mount-state", + "Proton Drive disconnected", + "The mount service stopped. Files aren't available until it restarts.", + ); + } +} + +/// Poll the daemon's in-flight transfers on a worker thread and repaint the +/// Activity group. Independently inflight-guarded from [`refresh_status`] so the +/// two cheap polls on the 2s tick don't gate each other. The group hides itself +/// when nothing is moving, so an idle account shows no Activity section. +pub(crate) fn refresh_transfers(ui: &Rc) { + if ui.status.transfers_inflight.get() { + return; + } + ui.status.transfers_inflight.set(true); + let rx = spawn_request(ui.dirs.control_socket(), Request::GetQueueStatus); + let ui = ui.clone(); + glib::spawn_future_local(async move { + let result = rx.recv().await; + ui.status.transfers_inflight.set(false); + match result { + Ok(Ok(Response::Transfers { items, jobs })) => repaint_transfers(&ui, &items, &jobs), + // Daemon unreachable or odd reply: clear the section rather than + // leave stale progress bars frozen on screen. + _ => repaint_transfers(&ui, &[], &[]), + } + }); +} + +/// Render the Activity group from a work snapshot: the daemon's jobs (scans, +/// batch counts, the local index) above the files moving under them. Rebuilds the +/// rows only when the set changes (count differs); on the common steady tick it +/// updates each bar's fraction and the label in place, so progress animates +/// without flicker. Hides the whole group when the daemon is idle. +pub(crate) fn repaint_transfers(ui: &Rc, items: &[TransferItem], jobs: &[JobItem]) { + let lines: Vec = jobs + .iter() + .map(job_line) + .chain(items.iter().map(transfer_line)) + .collect(); + + // The wire carries in-flight transfers only, with no completion event: the + // count falling to zero is what "the sync finished" looks like from here. + // Jobs are deliberately not counted — a bulk upload retires its scan job and + // starts its upload job mid-flight, which is not a thing finishing. + let previous = ui.status.active_transfers.replace(items.len()); + if items.is_empty() && previous > 0 { + let files = if previous == 1 { + "1 file".to_string() + } else { + format!("{previous} files") + }; + notify( + "sync-complete", + "Sync complete", + &format!("{files} finished transferring."), + ); + // A just-finished batch may have added files (bulk upload) the current + // listing doesn't show yet; refresh whichever listing is on screen. + reload_listing(ui); + } + + if lines.is_empty() { + if !ui.status.transfer_rows.borrow().is_empty() { + for tr in ui.status.transfer_rows.borrow_mut().drain(..) { + ui.status.transfers_group.remove(&tr.row); + } + } + ui.status.transfers_group.set_visible(false); + return; + } + + ui.status.transfers_group.set_visible(true); + + // Rebuild rows only when the count changes; otherwise reuse them in place. + if ui.status.transfer_rows.borrow().len() != lines.len() { + for tr in ui.status.transfer_rows.borrow_mut().drain(..) { + ui.status.transfers_group.remove(&tr.row); + } + for _ in &lines { + let row_box = gtk4::Box::new(gtk4::Orientation::Vertical, 4); + row_box.set_margin_top(8); + row_box.set_margin_bottom(8); + let label = gtk4::Label::builder().halign(gtk4::Align::Start).build(); + label.add_css_class("dim-label"); + let bar = gtk4::ProgressBar::new(); + row_box.append(&label); + row_box.append(&bar); + let row = adw::PreferencesRow::builder() + .activatable(false) + .child(&row_box) + .build(); + ui.status.transfers_group.add(&row); + ui.status.transfer_rows + .borrow_mut() + .push(TransferRow { row, label, bar }); + } + } + + for (line, tr) in lines.iter().zip(ui.status.transfer_rows.borrow().iter()) { + tr.label.set_text(&line.text); + match line.fraction { + Some(f) => tr.bar.set_fraction(f), + // No total to divide by: pulse so the bar still reads as "working". + None => tr.bar.pulse(), + } + } +} + +/// One Activity row for a daemon job: its title, plus whatever it can say about +/// where it is — a count when it has one, else what it is currently chewing on. +pub(crate) fn job_line(j: &JobItem) -> ActivityLine { + let text = match (j.total > 0, j.detail.is_empty()) { + (true, true) => format!("{} — {} of {}", j.title, j.done, j.total), + (true, false) => format!("{} — {} ({} of {})", j.title, j.detail, j.done, j.total), + (false, true) => format!("{}…", j.title), + (false, false) => format!("{} — {}…", j.title, j.detail), + }; + ActivityLine { + text, + fraction: (j.total > 0).then(|| (j.done as f64 / j.total as f64).min(1.0)), + } +} + +/// One Activity row for a file in flight: which way it's going, how far, how fast. +pub(crate) fn transfer_line(t: &TransferItem) -> ActivityLine { + let arrow = match t.direction { + TransferDirection::Download => "↓", + TransferDirection::Upload => "↑", + }; + if t.bytes_total == 0 { + ActivityLine { + text: format!( + "{arrow} {} — {} ({}/s)", + t.name, + human_bytes(t.bytes_completed), + human_bytes(t.speed_bytes_sec), + ), + fraction: None, + } + } else { + ActivityLine { + text: format!( + "{arrow} {} — {} of {} ({}/s)", + t.name, + human_bytes(t.bytes_completed), + human_bytes(t.bytes_total), + human_bytes(t.speed_bytes_sec), + ), + fraction: Some((t.bytes_completed as f64 / t.bytes_total as f64).min(1.0)), + } + } +} + +/// Fetch mount status + cache stats from the daemon on a worker thread and repaint +/// the mount line, cache bar and pin list on the reply. The daemon owns the cache +/// stats now (`used`/`budget`/`pins` ride along on [`Response::Status`]), so the +/// GUI never opens the on-disk cache itself. Skipped while a fetch is in flight so +/// the tick can't stack threads on a stalled daemon. +pub(crate) fn refresh_status(ui: &Rc) { + if ui.status.status_inflight.get() { + return; + } + ui.status.status_inflight.set(true); + let rx = spawn_request(ui.dirs.control_socket(), Request::Status); + let ui = ui.clone(); + glib::spawn_future_local(async move { + let result = rx.recv().await; + ui.status.status_inflight.set(false); + match result { + Ok(Ok(Response::Status { + mountpoint, + used, + budget, + pins, + online, + pending_uploads, + pending_changes, + .. + })) => { + set_mounted(&ui, true); + // The queue is the more useful thing to say when it has anything + // in it: it is why a file that looks saved is not on the remote + // yet, and offline is usually the reason it is still queued. + let queued = pending_summary(pending_uploads, pending_changes); + ui.status.mount_row.set_subtitle(&match (online, queued) { + (true, None) => format!("Mounted at {mountpoint}"), + (true, Some(q)) => format!("Mounted at {mountpoint} — {q}"), + (false, None) => { + format!("Mounted at {mountpoint} — offline, cached files only") + } + (false, Some(q)) => format!("Mounted at {mountpoint} — offline, {q}"), + }); + let fraction = if budget == 0 { + 0.0 + } else { + (used as f64 / budget as f64).min(1.0) + }; + ui.status.cache_bar.set_fraction(fraction); + ui.status.cache_label.set_text(&format!( + "{} of {} used", + human_bytes(used), + human_bytes(budget) + )); + repaint_pins(&ui, &pins, true); + } + // Daemon unreachable (still starting, or down): report not-mounted and + // grey out the unpin buttons in place, but leave the last-known pin + // rows and cache read-out so the page doesn't flicker on a blip. + _ => { + set_mounted(&ui, false); + ui.status.mount_row.set_subtitle("Not mounted"); + for r in ui.status.pin_rows.borrow().iter() { + if let Some(b) = &r.unpin { + b.set_sensitive(false); + } + } + } + } + }); +} + +/// Render the pins group from `pins`, with the unpin buttons enabled only while a +/// mount daemon is running (`mounted`). Diffs against the last batch by path: when +/// the set is unchanged (the common case on the 2s tick) it only flips the unpin +/// buttons' `sensitive` flag, avoiding the rebuild that used to flicker the list +/// and drop scroll/selection every tick. +pub(crate) fn repaint_pins(ui: &Rc, pins: &[pdfs_core::cache::Pin], mounted: bool) { + let desired: Vec = pins.iter().map(|p| p.path.clone()).collect(); + if ui.status.pins_state.borrow().as_ref() == Some(&desired) { + for r in ui.status.pin_rows.borrow().iter() { + if let Some(b) = &r.unpin { + b.set_sensitive(mounted); + } + } + return; + } + + for pr in ui.status.pin_rows.borrow_mut().drain(..) { + ui.status.pins_group.remove(&pr.row); + } + *ui.status.pins_state.borrow_mut() = Some(desired); + + if pins.is_empty() { + let row = adw::ActionRow::builder() + .title("No pinned files") + .subtitle("Right-click a file in the mount to keep it offline.") + .build(); + ui.status.pins_group.add(&row); + ui.status.pin_rows.borrow_mut().push(PinRow { row, unpin: None }); + return; + } + + for pin in pins { + let name = Path::new(&pin.path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(&pin.path) + .to_string(); + let row = adw::ActionRow::builder() + .title(&name) + .subtitle(&pin.path) + .build(); + let icon = gtk4::Image::from_icon_name("emblem-documents-symbolic"); + row.add_prefix(&icon); + + let unpin = gtk4::Button::builder() + .icon_name("user-trash-symbolic") + .valign(gtk4::Align::Center) + .tooltip_text("Unpin (remove offline copy)") + .sensitive(mounted) + .build(); + unpin.add_css_class("flat"); + let ui_btn = ui.clone(); + let path = pin.path.clone(); + unpin.connect_clicked(move |_| { + let socket = ui_btn.dirs.control_socket(); + match send(&socket, &Request::Unpin { path: path.clone() }) { + Ok(Response::Error { message, .. }) => tracing::error!("unpin failed: {message}"), + Ok(_) => refresh(&ui_btn), + Err(e) => tracing::error!("unpin request failed: {e}"), + } + }); + row.add_suffix(&unpin); + + ui.status.pins_group.add(&row); + ui.status.pin_rows.borrow_mut().push(PinRow { + row, + unpin: Some(unpin), + }); + } +} diff --git a/crates/pdfs-gui/src/app/pages/trash.rs b/crates/pdfs-gui/src/app/pages/trash.rs new file mode 100644 index 0000000..0bf9cb9 --- /dev/null +++ b/crates/pdfs-gui/src/app/pages/trash.rs @@ -0,0 +1,398 @@ +use crate::*; + +pub(crate) struct TrashState { + // Trash page. Trashed nodes are addressed by uid, not by path, so this page + // keeps no current-directory state — it re-lists from the daemon on show. + pub(crate) model: gio::ListStore, + pub(crate) content: gtk4::Stack, + pub(crate) status: adw::StatusPage, + pub(crate) retry: gtk4::Button, + /// Empties the trash; insensitive while it is empty (or unread). + pub(crate) empty: gtk4::Button, + /// "12 items" under the page title. + pub(crate) subtitle: gtk4::Label, +} + +/// The widgets of the Trash page that a load repaints. +pub(crate) struct TrashWidgets { + pub(crate) model: gio::ListStore, + pub(crate) list: gtk4::ListView, + pub(crate) content: gtk4::Stack, + pub(crate) status: adw::StatusPage, + pub(crate) retry: gtk4::Button, + pub(crate) empty: gtk4::Button, + pub(crate) refresh: gtk4::Button, + pub(crate) subtitle: gtk4::Label, +} + +/// The Trash page: a flat list of everything Drive is holding in the trash, each +/// row offering Restore and Delete Forever, with Empty Trash in the header. +/// +/// A trashed node has no path inside the mount — the daemon forgets it when it is +/// trashed — so unlike the Files page this one addresses entries by uid and always +/// re-lists from the server rather than from a cached listing. Row rendering needs +/// the [`Ui`] handle for its buttons, so the factory is installed in [`wire_trash`]. +pub(crate) fn build_trash_page() -> (gtk4::Widget, TrashWidgets) { + let model = gio::ListStore::new::(); + + let title = gtk4::Label::builder() + .label("Trash") + .halign(gtk4::Align::Start) + .build(); + title.add_css_class("title-2"); + let subtitle = gtk4::Label::builder().halign(gtk4::Align::Start).build(); + subtitle.add_css_class("dim-label"); + let titles = gtk4::Box::new(gtk4::Orientation::Vertical, 2); + titles.set_hexpand(true); + titles.append(&title); + titles.append(&subtitle); + + let empty = gtk4::Button::builder() + .label("Empty Trash") + .valign(gtk4::Align::Center) + .sensitive(false) + .build(); + empty.add_css_class("destructive-action"); + let refresh = refresh_button(); + + let header = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + header.append(&titles); + header.append(&refresh); + header.append(&empty); + + let retry = gtk4::Button::builder() + .label("Retry") + .halign(gtk4::Align::Center) + .build(); + retry.add_css_class("pill"); + retry.add_css_class("suggested-action"); + retry.set_visible(false); + let status = adw::StatusPage::builder() + .icon_name("user-trash-symbolic") + .vexpand(true) + .child(&retry) + .build(); + status.add_css_class("compact"); + + // No selection model: every action lives on the row it acts on, so a + // mis-aimed Delete Forever isn't one click away from a stale selection. + let list = gtk4::ListView::builder() + .model(>k4::NoSelection::new(Some(model.clone()))) + .build(); + let scroll = gtk4::ScrolledWindow::builder() + .vexpand(true) + .child(&list) + .build(); + + let content = gtk4::Stack::new(); + content.set_vexpand(true); + content.set_transition_type(gtk4::StackTransitionType::Crossfade); + content.add_named(&scroll, Some("list")); + content.add_named(&status, Some("status")); + + let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12); + inner.set_margin_top(12); + inner.set_margin_bottom(12); + inner.set_margin_start(12); + inner.set_margin_end(12); + inner.append(&header); + inner.append(&content); + + ( + inner.upcast(), + TrashWidgets { + model, + list, + content, + status, + retry, + empty, + refresh, + subtitle, + }, + ) +} + +/// Install the row factory and the Empty Trash button. The row's two buttons read +/// the entry off the [`gtk4::ListItem`] they were clicked on rather than a +/// captured copy, so a recycled row always acts on the item it currently shows. +pub(crate) fn wire_trash(ui: &Rc, list: >k4::ListView, empty: >k4::Button) { + let factory = gtk4::SignalListItemFactory::new(); + let ui_setup = ui.clone(); + factory.connect_setup(move |_, item| { + let item = item.downcast_ref::().unwrap(); + + let icon = gtk4::Image::builder().pixel_size(32).build(); + let name = gtk4::Label::builder() + .halign(gtk4::Align::Start) + .ellipsize(gtk4::pango::EllipsizeMode::Middle) + .build(); + let meta = gtk4::Label::builder().halign(gtk4::Align::Start).build(); + meta.add_css_class("dim-label"); + meta.add_css_class("caption"); + let text = gtk4::Box::new(gtk4::Orientation::Vertical, 2); + text.set_hexpand(true); + text.set_valign(gtk4::Align::Center); + text.append(&name); + text.append(&meta); + + let restore = gtk4::Button::builder() + .icon_name("edit-undo-symbolic") + .tooltip_text("Restore to its original folder") + .valign(gtk4::Align::Center) + .build(); + restore.add_css_class("flat"); + let purge = gtk4::Button::builder() + .icon_name("edit-delete-symbolic") + .tooltip_text("Delete permanently") + .valign(gtk4::Align::Center) + .build(); + purge.add_css_class("flat"); + + let ui_restore = ui_setup.clone(); + let item_restore = item.clone(); + restore.connect_clicked(move |_| { + if let Some(entry) = bound_entry(&item_restore) { + restore_entry(&ui_restore, &entry); + } + }); + let ui_purge = ui_setup.clone(); + let item_purge = item.clone(); + purge.connect_clicked(move |_| { + if let Some(entry) = bound_entry(&item_purge) { + prompt_delete_forever(&ui_purge, &entry); + } + }); + + let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); + row.set_margin_top(6); + row.set_margin_bottom(6); + row.set_margin_start(6); + row.set_margin_end(6); + row.append(&icon); + row.append(&text); + row.append(&restore); + row.append(&purge); + item.set_child(Some(&row)); + }); + factory.connect_bind(|_, item| { + let item = item.downcast_ref::().unwrap(); + let Some(entry) = bound_entry(item) else { + return; + }; + let row = item.child().and_downcast::().unwrap(); + let Some(icon) = row.first_child().and_downcast::() else { + return; + }; + icon.set_icon_name(Some(&format!("{}-symbolic", icon_base_for(&entry)))); + let Some(text) = icon.next_sibling().and_downcast::() else { + return; + }; + if let Some(name) = text.first_child().and_downcast::() { + name.set_label(&entry.name); + } + if let Some(meta) = text.last_child().and_downcast::() { + let kind = if entry.is_dir { + "Folder".to_string() + } else { + human_bytes(entry.size) + }; + meta.set_label(&format!("{kind} · {}", format_modified(entry.modified))); + } + }); + list.set_factory(Some(&factory)); + + let ui_empty = ui.clone(); + empty.connect_clicked(move |_| prompt_empty_trash(&ui_empty)); +} + +/// The [`DirEntry`] a list item is currently bound to, or `None` for an unbound +/// (recycled) row. +pub(crate) fn bound_entry(item: >k4::ListItem) -> Option { + let obj = item.item().and_downcast::()?; + let entry = obj.borrow::().clone(); + Some(entry) +} + +/// Fetch the trash listing and repaint the page. +pub(crate) fn load_trash(ui: &Rc) { + // Drop the old rows first: a stale row here would offer Restore on something + // that may already be gone. + ui.trash.model.remove_all(); + ui.trash.empty.set_sensitive(false); + trash_status( + ui, + "user-trash-symbolic", + "Loading…", + "Reading the trash.", + false, + ); + + ui.busy_begin(); + let rx = spawn_request(ui.dirs.control_socket(), Request::ListTrash); + let ui = ui.clone(); + glib::spawn_future_local(async move { + let result = rx.recv().await; + ui.busy_end(); + match result { + Ok(Ok(Response::Entries { entries })) => repaint_trash(&ui, &entries), + Ok(Ok(Response::Error { message, .. })) => trash_status( + &ui, + "dialog-warning-symbolic", + "Couldn't read the trash", + &message, + false, + ), + Ok(Ok(_)) => trash_status( + &ui, + "dialog-warning-symbolic", + "Couldn't read the trash", + "Unexpected reply from the mount service.", + false, + ), + Ok(Err(_)) | Err(_) => trash_unreachable(&ui), + } + }); +} + +/// The daemon didn't answer. Same split as the Files page: still starting (poll +/// again, no button) versus actually down (Retry, which restarts the service). +pub(crate) fn trash_unreachable(ui: &Rc) { + if service::is_failed() || !service::is_active() { + trash_status( + ui, + "network-offline-symbolic", + "Not connected", + "The Proton Drive mount service isn't running.", + true, + ); + return; + } + trash_status( + ui, + "folder-remote-symbolic", + "Connecting…", + "Waiting for the Proton Drive mount service to come up.", + false, + ); + let ui = ui.clone(); + glib::timeout_add_local_once(CONNECT_RETRY_INTERVAL, move || { + if ui.stack.visible_child_name().as_deref() == Some("trash") { + load_trash(&ui); + } + }); +} + +/// Show a status page in place of the trash list. +pub(crate) fn trash_status(ui: &Rc, icon: &str, title: &str, description: &str, retry: bool) { + ui.trash.status.set_icon_name(Some(icon)); + ui.trash.status.set_title(title); + ui.trash.status.set_description(Some(description)); + ui.trash.retry.set_visible(retry); + ui.trash.content.set_visible_child_name("status"); + ui.trash.subtitle.set_label(""); +} + +/// Repopulate the trash list, most recently modified first — the order in which a +/// user looks for what they just deleted. +pub(crate) fn repaint_trash(ui: &Rc, entries: &[DirEntry]) { + ui.trash.model.remove_all(); + ui.trash.empty.set_sensitive(!entries.is_empty()); + if entries.is_empty() { + trash_status( + ui, + "user-trash-symbolic", + "Trash is empty", + "Items you delete from Proton Drive show up here.", + false, + ); + return; + } + ui.trash.content.set_visible_child_name("list"); + ui.trash.subtitle.set_label(&match entries.len() { + 1 => "1 item".to_string(), + n => format!("{n} items"), + }); + + let mut sorted = entries.to_vec(); + sorted.sort_by_key(|e| std::cmp::Reverse(e.modified)); + for entry in sorted { + ui.trash.model.append(&BoxedAnyObject::new(entry)); + } +} + +/// Restore one trashed entry to the folder it was trashed from. +pub(crate) fn restore_entry(ui: &Rc, entry: &DirEntry) { + let name = entry.name.clone(); + run_mutation( + ui, + Request::Restore { + uids: vec![entry.uid.clone()], + }, + format!("Restored “{name}”"), + "Couldn't restore", + ); +} + +/// Confirm, then permanently delete one trashed entry. Irreversible, so it asks. +pub(crate) fn prompt_delete_forever(ui: &Rc, entry: &DirEntry) { + let win = ui_window(ui); + let uid = entry.uid.clone(); + let name = entry.name.clone(); + let dialog = adw::AlertDialog::builder() + .heading("Delete permanently") + .body(format!( + "Permanently delete “{name}”? This cannot be undone." + )) + .build(); + dialog.add_response("cancel", "Cancel"); + dialog.add_response("delete", "Delete Permanently"); + dialog.set_response_appearance("delete", 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 == "delete" { + run_mutation( + &ui, + Request::DeleteForever { + uids: vec![uid.clone()], + }, + format!("Deleted “{name}” permanently"), + "Couldn't delete", + ); + } + }); + dialog.present(win.as_ref()); +} + +/// Confirm, then permanently delete everything in the trash. +pub(crate) fn prompt_empty_trash(ui: &Rc) { + let win = ui_window(ui); + let count = ui.trash.model.n_items(); + let dialog = adw::AlertDialog::builder() + .heading("Empty Trash") + .body(format!( + "Permanently delete all {count} item(s) in the trash? This cannot be undone." + )) + .build(); + dialog.add_response("cancel", "Cancel"); + dialog.add_response("empty", "Empty Trash"); + dialog.set_response_appearance("empty", 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 == "empty" { + run_mutation( + &ui, + Request::EmptyTrash, + "Trash emptied".to_string(), + "Couldn't empty the trash", + ); + } + }); + dialog.present(win.as_ref()); +} diff --git a/crates/pdfs-gui/src/app/widgets/details.rs b/crates/pdfs-gui/src/app/widgets/details.rs new file mode 100644 index 0000000..972ad68 --- /dev/null +++ b/crates/pdfs-gui/src/app/widgets/details.rs @@ -0,0 +1,269 @@ +use crate::*; + +pub(crate) struct DetailsState { + /// Widgets in the details pane, repainted from the selected entry. + pub(crate) details: DetailsWidgets, + /// The entry the details pane is currently showing, so its buttons act on the + /// same one the user is looking at even after the model is repopulated. + pub(crate) details_entry: RefCell>, + /// Set while the details pane is being populated, so setting the offline + /// switch programmatically doesn't fire a pin/unpin round-trip. + pub(crate) details_suppress: Cell, + /// The grid's and list's selection models. Both wrap the one browser model, so + /// a selection in either drives the details pane. + pub(crate) grid_selection: gtk4::SingleSelection, + pub(crate) list_selection: gtk4::SingleSelection, +} + +/// The widgets in the browser's details pane that a selection repaints. +pub(crate) struct DetailsWidgets { + pub(crate) icon: gtk4::Image, + pub(crate) name: gtk4::Label, + pub(crate) kind: gtk4::Label, + pub(crate) size_row: adw::ActionRow, + pub(crate) modified_row: adw::ActionRow, + pub(crate) path_row: adw::ActionRow, + pub(crate) pin_row: adw::SwitchRow, + pub(crate) open_button: gtk4::Button, + pub(crate) rename_button: gtk4::Button, + pub(crate) trash_button: gtk4::Button, + pub(crate) close_button: gtk4::Button, +} + +/// The details pane shown beside the file views: a big type icon over the entry's +/// name, its properties, an offline (pin) toggle and the primary actions. Built +/// empty; [`repaint_details`] fills it from the selected [`DirEntry`] and +/// [`wire_details`] connects the buttons. +pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) { + let close_button = gtk4::Button::builder() + .icon_name("window-close-symbolic") + .tooltip_text("Close details") + .halign(gtk4::Align::End) + .build(); + close_button.add_css_class("flat"); + close_button.add_css_class("circular"); + + let icon = gtk4::Image::builder() + .icon_name("text-x-generic-symbolic") + .pixel_size(64) + .build(); + let name = gtk4::Label::builder() + .wrap(true) + .wrap_mode(gtk4::pango::WrapMode::WordChar) + .justify(gtk4::Justification::Center) + .build(); + name.add_css_class("title-4"); + let kind = gtk4::Label::new(None); + kind.add_css_class("dim-label"); + kind.add_css_class("caption"); + + let head = gtk4::Box::new(gtk4::Orientation::Vertical, 6); + head.set_margin_bottom(6); + head.append(&icon); + head.append(&name); + head.append(&kind); + + let props = adw::PreferencesGroup::new(); + let size_row = adw::ActionRow::builder() + .title("Size") + .subtitle("—") + .build(); + size_row.add_css_class("property"); + let modified_row = adw::ActionRow::builder() + .title("Modified") + .subtitle("—") + .build(); + modified_row.add_css_class("property"); + let path_row = adw::ActionRow::builder() + .title("Location") + .subtitle("—") + .subtitle_lines(3) + .build(); + path_row.add_css_class("property"); + props.add(&size_row); + props.add(&modified_row); + props.add(&path_row); + + let offline = adw::PreferencesGroup::new(); + let pin_row = adw::SwitchRow::builder() + .title("Available offline") + .subtitle("Keep a local copy on this device.") + .build(); + offline.add(&pin_row); + + let open_button = gtk4::Button::builder().label("Open").build(); + open_button.add_css_class("suggested-action"); + open_button.add_css_class("pill"); + let rename_button = gtk4::Button::builder().label("Rename").build(); + rename_button.add_css_class("pill"); + let trash_button = gtk4::Button::builder().label("Move to Trash").build(); + trash_button.add_css_class("destructive-action"); + trash_button.add_css_class("pill"); + + let actions = gtk4::Box::new(gtk4::Orientation::Vertical, 6); + actions.set_margin_top(6); + actions.append(&open_button); + actions.append(&rename_button); + actions.append(&trash_button); + + let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12); + inner.set_margin_top(12); + inner.set_margin_bottom(12); + inner.set_margin_start(12); + inner.set_margin_end(12); + inner.append(&close_button); + inner.append(&head); + inner.append(&props); + inner.append(&offline); + inner.append(&actions); + + let scroll = gtk4::ScrolledWindow::builder() + .hscrollbar_policy(gtk4::PolicyType::Never) + .child(&inner) + .build(); + let pane = adw::ToolbarView::new(); + pane.set_content(Some(&scroll)); + pane.add_css_class("background"); + + ( + pane.upcast(), + DetailsWidgets { + icon, + name, + kind, + size_row, + modified_row, + path_row, + pin_row, + open_button, + rename_button, + trash_button, + close_button, + }, + ) +} + +/// Connect the details pane: mirror the grid's and list's selection into it, and +/// wire its buttons back onto the entry it's showing. +pub(crate) fn wire_details(ui: &Rc) { + // Both views share one model but have their own selection, so watch both. + for selection in [ui.details.grid_selection.clone(), ui.details.list_selection.clone()] { + let ui_sel = ui.clone(); + selection.connect_selection_changed(move |sel, _, _| { + match entry_at(sel.model().as_ref(), sel.selected()) { + Some(entry) => show_details(&ui_sel, &entry), + None => hide_details(&ui_sel), + } + }); + } + + let ui_close = ui.clone(); + ui.details.details.close_button.connect_clicked(move |_| { + ui_close + .details.grid_selection + .set_selected(gtk4::INVALID_LIST_POSITION); + ui_close + .details.list_selection + .set_selected(gtk4::INVALID_LIST_POSITION); + hide_details(&ui_close); + }); + + let ui_open = ui.clone(); + ui.details.details.open_button.connect_clicked(move |_| { + // Bind the clone to a local so the `Ref` from `borrow()` is dropped before + // `activate_entry` runs; it navigates and repaints the details pane, which + // takes `details_entry.borrow_mut()` and would panic against a live borrow. + let entry = ui_open.details.details_entry.borrow().clone(); + if let Some(entry) = entry { + activate_entry(&ui_open, &entry); + } + }); + let ui_rename = ui.clone(); + ui.details.details.rename_button.connect_clicked(move |_| { + let entry = ui_rename.details.details_entry.borrow().clone(); + if let Some(entry) = entry { + prompt_rename(&ui_rename, &entry); + } + }); + let ui_trash = ui.clone(); + ui.details.details.trash_button.connect_clicked(move |_| { + let entry = ui_trash.details.details_entry.borrow().clone(); + if let Some(entry) = entry { + prompt_delete(&ui_trash, &entry); + } + }); + let ui_pin = ui.clone(); + ui.details.details.pin_row.connect_active_notify(move |row| { + if ui_pin.details.details_suppress.get() { + return; + } + let Some(entry) = ui_pin.details.details_entry.borrow().clone() else { + return; + }; + // The switch reads the *desired* state; `toggle_pin` derives the request + // from the entry's current one, so only act when they actually differ. + if row.is_active() != entry.pinned { + toggle_pin(&ui_pin, &entry); + } + }); +} + +/// Reveal the details pane and paint it from `entry`. +pub(crate) fn show_details(ui: &Rc, entry: &DirEntry) { + ui.details.details_suppress.set(true); + let d = &ui.details.details; + d.icon.set_icon_name(Some(icon_base_for(entry))); + d.name.set_label(&entry.name); + d.kind + .set_label(if entry.is_dir { "Folder" } else { "File" }); + d.size_row.set_subtitle(&if entry.is_dir { + "—".to_string() + } else { + human_bytes(entry.size) + }); + d.size_row.set_visible(!entry.is_dir); + d.modified_row + .set_subtitle(&format_modified(entry.modified)); + let rel = entry_rel(ui, entry); + let parent = match rel.rfind('/') { + Some(i) => &rel[..i], + None => "", + }; + d.path_row.set_subtitle(if parent.is_empty() { + "Proton Drive" + } else { + parent + }); + d.pin_row.set_active(entry.pinned); + d.pin_row.set_sensitive(*ui.mounted.borrow()); + d.open_button + .set_label(if entry.is_dir { "Open folder" } else { "Open" }); + ui.details.details_suppress.set(false); + + *ui.details.details_entry.borrow_mut() = Some(entry.clone()); + // Reveal the pane on idle, not inline. This runs from `selection_changed`, + // which fires on the *first* press of a double-click; mutating the widget + // tree here cancels GtkGridView's multi-press tracking, so the second press + // restarts the count and `activate` never fires — the folder needs two + // double-clicks. Deferring lets the click gesture finish first. Guard on the + // entry still being present so a navigation that clears it wins the race. + let ui = ui.clone(); + glib::idle_add_local_once(move || { + if ui.details.details_entry.borrow().is_some() { + ui.browser.split.set_show_sidebar(true); + } + }); +} + +/// Hide the details pane and forget the entry it was showing, so a stale entry +/// can't be acted on after the listing moves on. +pub(crate) fn hide_details(ui: &Rc) { + ui.browser.split.set_show_sidebar(false); + *ui.details.details_entry.borrow_mut() = None; +} + +/// The entry highlighted in whichever browser view is on screen, if any. Backs the +/// F2 / Delete shortcuts. +pub(crate) fn selected_entry(ui: &Rc) -> Option { + ui.details.details_entry.borrow().clone() +} diff --git a/crates/pdfs-gui/src/app/widgets/mod.rs b/crates/pdfs-gui/src/app/widgets/mod.rs new file mode 100644 index 0000000..d798a7f --- /dev/null +++ b/crates/pdfs-gui/src/app/widgets/mod.rs @@ -0,0 +1,2 @@ +pub(crate) mod details; +pub(crate) mod share_dialog; diff --git a/crates/pdfs-gui/src/app/widgets/share_dialog.rs b/crates/pdfs-gui/src/app/widgets/share_dialog.rs new file mode 100644 index 0000000..c36510e --- /dev/null +++ b/crates/pdfs-gui/src/app/widgets/share_dialog.rs @@ -0,0 +1,461 @@ +use crate::*; + +/// The roles offered in the Share dialog's dropdowns, in index order. +pub(crate) const SHARE_ROLES: [&str; 3] = ["Viewer", "Editor", "Admin"]; + +/// Map a role dropdown index to the wire role string. +pub(crate) fn role_index_to_wire(idx: u32) -> &'static str { + match idx { + 1 => "editor", + 2 => "admin", + _ => "viewer", + } +} + +/// Map a wire role string to its dropdown index. +pub(crate) fn role_wire_to_index(role: &str) -> u32 { + match role { + "editor" => 1, + "admin" => 2, + _ => 0, + } +} + +/// The mutable state behind an open Share dialog, so the invite/role/link +/// handlers can rebuild the people and link sections after each change without +/// tearing the whole dialog down. +pub(crate) struct ShareDialog { + pub(crate) ui: Rc, + /// The node's mountpoint-relative path — how every request addresses it. + pub(crate) rel: String, + pub(crate) people: adw::PreferencesGroup, + pub(crate) link_group: adw::PreferencesGroup, + pub(crate) people_rows: RefCell>, + pub(crate) link_rows: RefCell>, +} + +/// Open the per-node Share dialog: invite Proton/external users, manage who has +/// access and their roles, and create/copy/remove a public link. +pub(crate) fn open_share_dialog(ui: &Rc, entry: &DirEntry) { + if !*ui.mounted.borrow() { + toast_error(ui, "Can't share", "Proton Drive isn't connected."); + return; + } + let rel = entry_rel(ui, entry); + + let toolbar = adw::ToolbarView::new(); + let header = adw::HeaderBar::new(); + header.set_title_widget(Some(&adw::WindowTitle::new("Share", &entry.name))); + toolbar.add_top_bar(&header); + + // Invite section: emails + role + optional message. + let invite_group = adw::PreferencesGroup::builder() + .title("Invite people") + .description("Proton and non-Proton email addresses, separated by spaces or commas.") + .build(); + let email_row = adw::EntryRow::builder().title("Email addresses").build(); + let role_model = gtk4::StringList::new(&SHARE_ROLES); + let role_drop = gtk4::DropDown::builder() + .model(&role_model) + .selected(0) + .valign(gtk4::Align::Center) + .build(); + let role_wrap = adw::ActionRow::builder().title("Role").build(); + role_wrap.add_suffix(&role_drop); + let message_row = adw::EntryRow::builder().title("Message (optional)").build(); + let invite_btn = gtk4::Button::builder() + .label("Send Invitations") + .halign(gtk4::Align::End) + .margin_top(6) + .build(); + invite_btn.add_css_class("suggested-action"); + let invite_wrap = adw::PreferencesRow::builder() + .activatable(false) + .child(&invite_btn) + .build(); + invite_group.add(&email_row); + invite_group.add(&role_wrap); + invite_group.add(&message_row); + invite_group.add(&invite_wrap); + + let people = adw::PreferencesGroup::builder() + .title("People with access") + .build(); + let link_group = adw::PreferencesGroup::builder() + .title("Public link") + .build(); + + let content = gtk4::Box::new(gtk4::Orientation::Vertical, 18); + content.set_margin_top(12); + content.set_margin_bottom(12); + content.set_margin_start(12); + content.set_margin_end(12); + content.append(&invite_group); + content.append(&people); + content.append(&link_group); + let clamp = adw::Clamp::builder().child(&content).build(); + let scroll = gtk4::ScrolledWindow::builder() + .vexpand(true) + .hscrollbar_policy(gtk4::PolicyType::Never) + .child(&clamp) + .build(); + toolbar.set_content(Some(&scroll)); + + let dialog = adw::Dialog::builder() + .title("Share") + .content_width(480) + .content_height(560) + .child(&toolbar) + .build(); + + let state = Rc::new(ShareDialog { + ui: ui.clone(), + rel, + people, + link_group, + people_rows: RefCell::new(Vec::new()), + link_rows: RefCell::new(Vec::new()), + }); + + // Enter in either free-text field sends the invitations, no mouse needed. + let btn = invite_btn.clone(); + email_row.connect_entry_activated(move |_| btn.emit_clicked()); + let btn = invite_btn.clone(); + message_row.connect_entry_activated(move |_| btn.emit_clicked()); + + // Invite button. + let state_inv = state.clone(); + invite_btn.connect_clicked(move |_| { + let raw = email_row.text(); + let emails: Vec = raw + .split([',', ' ', ';']) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect(); + if emails.is_empty() { + toast_error(&state_inv.ui, "Couldn't share", "Enter at least one email."); + return; + } + let role = role_index_to_wire(role_drop.selected()).to_string(); + let msg = message_row.text().trim().to_string(); + let message = if msg.is_empty() { None } else { Some(msg) }; + let email_clear = email_row.clone(); + let msg_clear = message_row.clone(); + share_dialog_op( + &state_inv, + Request::ShareNode { + path: state_inv.rel.clone(), + emails, + role, + message, + }, + "Invitations sent", + "Couldn't send invitations", + Some(Box::new(move || { + email_clear.set_text(""); + msg_clear.set_text(""); + })), + ); + }); + + share_dialog_reload(&state); + dialog.present(ui_window(ui).as_ref()); +} + +/// Re-fetch the node's share and rebuild the people + public-link sections. +pub(crate) fn share_dialog_reload(state: &Rc) { + let rx = spawn_request( + state.ui.dirs.control_socket(), + Request::ListShare { + path: state.rel.clone(), + }, + ); + let state = state.clone(); + glib::spawn_future_local(async move { + match rx.recv().await { + Ok(Ok(Response::Share { entries, link })) => { + repaint_share_people(&state, &entries); + repaint_share_link(&state, link.as_ref()); + } + Ok(Ok(Response::Error { message, .. })) => { + toast_error(&state.ui, "Couldn't load sharing", &message) + } + _ => toast_error( + &state.ui, + "Couldn't load sharing", + "The mount service didn't respond.", + ), + } + }); +} + +/// Rebuild the "People with access" rows from a fresh share listing. +pub(crate) fn repaint_share_people(state: &Rc, entries: &[ShareEntry]) { + for row in state.people_rows.borrow_mut().drain(..) { + state.people.remove(&row); + } + let mut rows: Vec = Vec::new(); + if entries.is_empty() { + let row = dim_row("No one else has access yet."); + state.people.add(&row); + rows.push(row.upcast()); + *state.people_rows.borrow_mut() = rows; + return; + } + for entry in entries { + let subtitle = match entry.kind { + ShareEntryKind::Member => "Member".to_string(), + ShareEntryKind::ProtonInvite => "Invited (pending)".to_string(), + ShareEntryKind::ExternalInvite => "Invited (external, pending)".to_string(), + }; + let row = adw::ActionRow::builder() + .title(&entry.email) + .subtitle(&subtitle) + .build(); + + // External invites can't have their role changed; show it read-only. + // Members and Proton invites get a role dropdown. + if matches!( + entry.kind, + ShareEntryKind::Member | ShareEntryKind::ProtonInvite + ) { + let model = gtk4::StringList::new(&SHARE_ROLES); + let drop = gtk4::DropDown::builder() + .model(&model) + .selected(role_wire_to_index(&entry.role)) + .valign(gtk4::Align::Center) + .build(); + let state_role = state.clone(); + let id = entry.id.clone(); + let kind = entry.kind; + drop.connect_selected_notify(move |d| { + share_dialog_op( + &state_role, + Request::UpdateShareRole { + path: state_role.rel.clone(), + id: id.clone(), + kind, + role: role_index_to_wire(d.selected()).to_string(), + }, + "Role updated", + "Couldn't update the role", + None, + ); + }); + row.add_suffix(&drop); + } else { + let label = gtk4::Label::builder() + .label(capitalize(&entry.role)) + .valign(gtk4::Align::Center) + .build(); + label.add_css_class("dim-label"); + row.add_suffix(&label); + } + + let remove = gtk4::Button::builder() + .icon_name("user-trash-symbolic") + .tooltip_text("Remove access") + .valign(gtk4::Align::Center) + .build(); + remove.add_css_class("flat"); + let state_rm = state.clone(); + let id = entry.id.clone(); + let kind = entry.kind; + remove.connect_clicked(move |_| { + share_dialog_op( + &state_rm, + Request::RemoveShareEntry { + path: state_rm.rel.clone(), + id: id.clone(), + kind, + }, + "Access removed", + "Couldn't remove access", + None, + ); + }); + row.add_suffix(&remove); + + state.people.add(&row); + rows.push(row.upcast()); + } + *state.people_rows.borrow_mut() = rows; +} + +/// Rebuild the public-link section: an existing link (copy / remove) or a +/// create control. +pub(crate) fn repaint_share_link(state: &Rc, link: Option<&PublicLinkInfo>) { + for row in state.link_rows.borrow_mut().drain(..) { + state.link_group.remove(&row); + } + let mut rows: Vec = Vec::new(); + + match link { + Some(link) => { + let url = link.url.clone().unwrap_or_default(); + let subtitle = if link.has_password { + format!("Anyone with the link ({}) · password-protected", link.role) + } else { + format!("Anyone with the link ({})", link.role) + }; + let row = adw::ActionRow::builder() + .title(if url.is_empty() { "Public link" } else { &url }) + .subtitle(&subtitle) + .build(); + row.add_css_class("property"); + + if !url.is_empty() { + let copy = gtk4::Button::builder() + .icon_name("edit-copy-symbolic") + .tooltip_text("Copy link") + .valign(gtk4::Align::Center) + .build(); + copy.add_css_class("flat"); + let state_copy = state.clone(); + let url_copy = url.clone(); + copy.connect_clicked(move |btn| { + btn.clipboard().set_text(&url_copy); + toast(&state_copy.ui, "Link copied"); + }); + row.add_suffix(©); + } + + let remove = gtk4::Button::builder() + .icon_name("user-trash-symbolic") + .tooltip_text("Remove link") + .valign(gtk4::Align::Center) + .build(); + remove.add_css_class("flat"); + let state_rm = state.clone(); + let id = link.id.clone(); + remove.connect_clicked(move |_| { + share_dialog_op( + &state_rm, + Request::RemovePublicLink { + path: state_rm.rel.clone(), + id: id.clone(), + }, + "Public link removed", + "Couldn't remove the link", + None, + ); + }); + row.add_suffix(&remove); + + state.link_group.add(&row); + rows.push(row.upcast()); + } + None => { + let role_model = gtk4::StringList::new(&["Viewer", "Editor"]); + let role_drop = gtk4::DropDown::builder() + .model(&role_model) + .selected(0) + .valign(gtk4::Align::Center) + .build(); + let role_row = adw::ActionRow::builder().title("Link role").build(); + role_row.add_suffix(&role_drop); + let pw_row = adw::PasswordEntryRow::builder() + .title("Password (optional)") + .build(); + let create = gtk4::Button::builder() + .label("Create Public Link") + .halign(gtk4::Align::End) + .margin_top(6) + .build(); + create.add_css_class("suggested-action"); + let create_wrap = adw::PreferencesRow::builder() + .activatable(false) + .child(&create) + .build(); + + // Enter in the password field creates the link. + let btn = create.clone(); + pw_row.connect_entry_activated(move |_| btn.emit_clicked()); + + let state_c = state.clone(); + let pw_for = pw_row.clone(); + create.connect_clicked(move |_| { + let role = if role_drop.selected() == 1 { + "editor" + } else { + "viewer" + } + .to_string(); + let pw = pw_for.text().to_string(); + let password = if pw.is_empty() { None } else { Some(pw) }; + share_dialog_create_link(&state_c, role, password); + }); + + state.link_group.add(&role_row); + state.link_group.add(&pw_row); + state.link_group.add(&create_wrap); + rows.push(role_row.upcast()); + rows.push(pw_row.upcast()); + rows.push(create_wrap.upcast()); + } + } + *state.link_rows.borrow_mut() = rows; +} + +/// Create a public link, then reload the dialog so the copy/remove controls +/// replace the create form (and the freshly minted URL is shown). +pub(crate) fn share_dialog_create_link(state: &Rc, role: String, password: Option) { + let rx = spawn_request( + state.ui.dirs.control_socket(), + Request::CreatePublicLink { + path: state.rel.clone(), + role, + password, + expires: None, + }, + ); + let state = state.clone(); + glib::spawn_future_local(async move { + match rx.recv().await { + Ok(Ok(Response::PublicLink { .. })) => { + toast(&state.ui, "Public link created"); + share_dialog_reload(&state); + } + Ok(Ok(Response::Error { message, .. })) => { + toast_error(&state.ui, "Couldn't create the link", &message) + } + _ => toast_error( + &state.ui, + "Couldn't create the link", + "The mount service didn't respond.", + ), + } + }); +} + +/// Run a Share-dialog mutation, then reload the dialog on success. `on_success` +/// runs an extra UI tweak (e.g. clearing the invite fields) before the reload. +pub(crate) fn share_dialog_op( + state: &Rc, + req: Request, + done: &'static str, + failed: &'static str, + on_success: Option>, +) { + let rx = spawn_request(state.ui.dirs.control_socket(), req); + let state = state.clone(); + glib::spawn_future_local(async move { + match rx.recv().await { + Ok(Ok(Response::Ok { .. })) => { + if let Some(cb) = on_success { + cb(); + } + toast(&state.ui, done); + share_dialog_reload(&state); + } + Ok(Ok(Response::Error { message, .. })) => toast_error(&state.ui, failed, &message), + _ => { + // A role dropdown that failed is now out of sync with the server; + // reload to snap it back. + toast_error(&state.ui, failed, "The mount service didn't respond."); + share_dialog_reload(&state); + } + } + }); +} diff --git a/crates/pdfs-gui/src/prompt.rs b/crates/pdfs-gui/src/prompt.rs index e245cd3..d86ee1f 100644 --- a/crates/pdfs-gui/src/prompt.rs +++ b/crates/pdfs-gui/src/prompt.rs @@ -977,7 +977,7 @@ impl Ui { xdg_open(Path::new(&path)); window.close(); } - Ok(Ok(Response::Error { message })) => { + Ok(Ok(Response::Error { message, .. })) => { ui.hint.set_label(&format!("Could not open: {message}")); } _ => ui.hint.set_label("Could not reach the daemon"),