diff --git a/crates/pdfs-cli/src/main.rs b/crates/pdfs-cli/src/main.rs index ae52362..4f33c1c 100644 --- a/crates/pdfs-cli/src/main.rs +++ b/crates/pdfs-cli/src/main.rs @@ -13,8 +13,9 @@ use pdfs_core::auth; use pdfs_core::cache::ContentCache; use pdfs_core::config::AppDirs; use pdfs_core::control::{ - ErrorKind, MountKind, RefreshScope, Request as CtlRequest, Response as CtlResponse, - RestoreItem as CtlRestoreItem, ShareEntryKind, SyncPhase, pending_summary, + ConflictKeep, ErrorKind, MountKind, RefreshScope, Request as CtlRequest, + Response as CtlResponse, RestoreItem as CtlRestoreItem, ShareEntryKind, SyncPhase, + pending_summary, }; use pdfs_core::db::Db; use pdfs_core::service; @@ -327,6 +328,11 @@ enum Command { #[command(subcommand)] action: SyncCmd, }, + /// List `(sync-conflict …)` copies, or resolve one. + Conflicts { + #[command(subcommand)] + action: Option, + }, /// Share a file or folder with Proton and/or external email addresses. Share { /// File/folder path, inside the mountpoint or relative to it. @@ -521,6 +527,22 @@ enum DeviceCmd { }, } +#[derive(Subcommand)] +enum ConflictCmd { + /// Decide which side of a conflict to keep. Whatever is removed goes to Trash. + Resolve { + /// The conflict copy, absolute or relative to the mountpoint. + path: PathBuf, + /// `original` trashes the copy; `copy` trashes the original and gives the + /// copy its name; `both` renames the copy to `--name`. + #[arg(long, value_parser = ["original", "copy", "both"])] + keep: String, + /// New name for the copy with `--keep both`. + #[arg(long, required_if_eq("keep", "both"))] + name: Option, + }, +} + #[derive(Subcommand)] enum SyncCmd { /// Add a local folder to this machine's device and upload its contents. @@ -707,6 +729,7 @@ fn main() -> Result<()> { Command::Refresh { target, full } => cmd_refresh(target, full), Command::Devices { action } => cmd_devices(action), Command::Sync { action } => cmd_sync(action), + Command::Conflicts { action } => cmd_conflicts(action), Command::Share { path, emails, @@ -871,6 +894,47 @@ fn cmd_sync(action: SyncCmd) -> Result<()> { Ok(()) } +/// `pdfs conflicts`: list conflict copies, or resolve one. +fn cmd_conflicts(action: Option) -> Result<()> { + if let Some(ConflictCmd::Resolve { path, keep, name }) = action { + let keep = match keep.as_str() { + "original" => ConflictKeep::Original, + "copy" => ConflictKeep::Copy, + _ => ConflictKeep::Both { + name: name.unwrap_or_default(), + }, + }; + let path = path_arg(&path)?; + return ok_or_bail(control_request(CtlRequest::ResolveConflict { path, keep })?); + } + let response = control_request(CtlRequest::ListConflicts)?; + if emit_json(&response)? { + return Ok(()); + } + match response { + CtlResponse::Conflicts { items } if items.is_empty() => println!("No conflicts."), + CtlResponse::Conflicts { items } => { + for c in items { + println!("{}", c.path); + let verdict = match (c.original_exists, c.identical) { + (false, _) => "original is gone".to_string(), + (true, true) => format!("identical to {}", c.original_path), + (true, false) => format!( + "differs from {} ({} vs {})", + c.original_path, + human_bytes(c.size), + human_bytes(c.original_size.unwrap_or_default()), + ), + }; + println!(" {verdict}"); + } + } + CtlResponse::Error { message, kind } => bail!("{}", cli_error(kind, &message)), + other => bail!("unexpected response: {other:?}"), + } + Ok(()) +} + /// `pdfs sync queue`: what has not reached Proton Drive yet, and why. fn cmd_sync_queue() -> Result<()> { let response = control_request(CtlRequest::ListPendingOps)?; diff --git a/crates/pdfs-core/src/control.rs b/crates/pdfs-core/src/control.rs index 629c731..0ae028c 100644 --- a/crates/pdfs-core/src/control.rs +++ b/crates/pdfs-core/src/control.rs @@ -396,6 +396,13 @@ pub enum Request { /// Retry a backed-off queued op now instead of waiting out its backoff; /// every failed op when `id` is `None`. Replies with [`Response::Ok`]. RetryPendingOp { id: Option }, + /// List the `(sync-conflict …)` copies under My Files. Replies with + /// [`Response::Conflicts`]. + ListConflicts, + /// Resolve one conflict copy (`path` mountpoint-relative or absolute). + /// Everything removed goes to the Proton trash. Replies with + /// [`Response::Ok`]. + ResolveConflict { path: String, keep: ConflictKeep }, /// List the folders under this machine's device that can be synced here, /// each with a proposed local path (features.md 5.2). Replies with /// [`Response::RestorableFolders`]. @@ -638,6 +645,36 @@ pub struct DeviceInfo { pub adopted: bool, } +/// One `(sync-conflict …)` copy and the file it is a copy of (in +/// [`Response::Conflicts`]). +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct ConflictInfo { + /// Mountpoint-relative path of the copy. + pub path: String, + /// Mountpoint-relative path of the file it is a copy of. + pub original_path: String, + /// False when the original is gone and only the copy is left. + pub original_exists: bool, + pub size: u64, + /// Unix seconds. + pub modified: i64, + pub original_size: Option, + pub original_modified: Option, + /// Same size and content hash as the original: keeping either loses nothing. + pub identical: bool, +} + +/// Which side of a conflict to keep (in [`Request::ResolveConflict`]). +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub enum ConflictKeep { + /// Keep the original; the copy goes to Trash. + Original, + /// Keep the copy under the original's name; the original goes to Trash. + Copy, + /// Keep both; the copy is renamed to `name`. + Both { name: String }, +} + /// One queued upload or change (in [`Response::PendingOps`]). #[derive(Serialize, Deserialize, Debug, Clone)] pub struct PendingOpInfo { @@ -1736,6 +1773,8 @@ pub enum Response { SyncFolders { items: Vec }, /// The upload/change queue (reply to [`Request::ListPendingOps`]). PendingOps { items: Vec }, + /// Conflict copies (reply to [`Request::ListConflicts`]). + Conflicts { items: Vec }, /// Folders offered for restore (reply to [`Request::ListRestorableFolders`]). RestorableFolders { items: Vec }, /// A node's share: members + pending invitations, and its public link if any diff --git a/crates/pdfs-fuse/src/conflicts.rs b/crates/pdfs-fuse/src/conflicts.rs new file mode 100644 index 0000000..77868c1 --- /dev/null +++ b/crates/pdfs-fuse/src/conflicts.rs @@ -0,0 +1,156 @@ +//! Listing and resolving `(sync-conflict …)` copies for the user. +//! +//! The sweep ([`crate::sweep`]) removes copies it can prove identical and flags +//! the rest once in the activity feed. That leaves divergent copies sitting in +//! folders with nothing but a name to find them by. This module lists them with +//! the file they are a copy of, and resolves one the way the user chooses: +//! +//! - **Keep the original**: trash the copy. +//! - **Keep the copy**: trash the original, then give the copy its name. +//! - **Keep both**: rename the copy to a name of the user's choosing. +//! +//! Every resolution goes through the Proton trash, never a delete, so a wrong +//! choice is undone by restoring from Trash. Resolution refuses to touch a file +//! that is open, being written, or still owed an upload, for the same reason the +//! sweep does: the node is about to change under the decision. +//! +//! Only copies under My Files are handled here. A copy inside a mirror folder is +//! a local file first; the sync engine owns it. + +use super::*; +use pdfs_core::control::{ConflictInfo, ConflictKeep}; +use sweep::conflict_base_name; + +impl Core { + /// Every conflict copy under My Files, with the file it is a copy of. + pub(crate) fn list_conflicts(&self) -> CoreResult> { + let stored = self + .db + .load_all() + .map_err(|e| CoreError::internal(format!("listing conflicts: {e:?}")))?; + let nodes: Vec = stored + .into_iter() + .map(|stored| stored.node) + .filter(|node| !node.trashed && !node.is_folder() && self.is_own_or_virtual(&node.uid)) + .collect(); + let mut by_parent_name: HashMap<(Option<&NodeUid>, &str), &Node> = HashMap::new(); + for node in &nodes { + by_parent_name.insert((node.parent_uid.as_ref(), node.name.as_str()), node); + } + let root = self.primary_root_uid.to_string(); + let mut items = Vec::new(); + for node in &nodes { + let Some(base) = conflict_base_name(&node.name) else { + continue; + }; + let Ok(Some(path)) = self.db.path_relative_to(&root, &node.uid.to_string()) else { + continue; + }; + let original = by_parent_name + .get(&(node.parent_uid.as_ref(), base.as_str())) + .copied(); + items.push(ConflictInfo { + original_path: sibling_path(&path, &base), + original_exists: original.is_some(), + size: node_size(node), + modified: node.modification_time, + original_size: original.map(node_size), + original_modified: original.map(|o| o.modification_time), + identical: original.is_some_and(|o| { + node_size(o) == node_size(node) + && node_content_sha1(node) + .is_some_and(|sha| node_content_sha1(o).as_deref() == Some(&sha)) + }), + path, + }); + } + items.sort_by(|a, b| a.path.cmp(&b.path)); + Ok(items) + } + + /// Resolve the conflict copy at mountpoint-relative `copy`. Returns a line + /// for the user saying what happened. + pub(crate) fn resolve_conflict(&self, copy: &Path, keep: &ConflictKeep) -> CoreResult { + let copy_name = copy + .file_name() + .and_then(|n| n.to_str()) + .ok_or_else(|| CoreError::invalid("the conflict copy has no file name"))?; + let base = conflict_base_name(copy_name) + .ok_or_else(|| CoreError::invalid(format!("{copy_name} is not a conflict copy")))?; + let original = copy.with_file_name(&base); + self.require_settled(copy)?; + let message = match keep { + ConflictKeep::Original => { + self.delete(copy)?; + self.log_activity(ActivityKind::Trash, copy_name, format!("kept {base}"), true); + format!("kept {base}; the copy is in Trash") + } + ConflictKeep::Copy => { + self.require_settled(&original)?; + self.delete(&original)?; + // The original is already in Trash. A failed rename leaves the + // copy under its conflict name, which loses nothing — say so + // rather than leave the user guessing where their file went. + self.rename(copy, &base).map_err(|e| { + e.context(&format!( + "{base} was moved to Trash, but the copy kept its conflict name" + )) + })?; + self.log_activity( + ActivityKind::Rename, + &base, + format!("kept the conflict copy {copy_name}"), + true, + ); + format!("kept the copy as {base}; the previous version is in Trash") + } + ConflictKeep::Both { name } => { + let name = name.trim(); + if name == base || name == copy_name { + return Err(CoreError::invalid("choose a name that no other file has")); + } + self.rename(copy, name)?; + self.log_activity(ActivityKind::Rename, name, format!("was {copy_name}"), true); + format!("kept both; the copy is now {name}") + } + }; + Ok(message) + } + + /// Refuse to resolve around a file that is open, being written, or still + /// owed an upload: the decision would be about content that is changing. + fn require_settled(&self, rel: &Path) -> CoreResult<()> { + let (_, uid) = self.resolve(rel)?; + let queued = self.db.has_any_op(&uid.to_string()).unwrap_or(true); + if queued || self.is_busy(&uid) { + return Err(CoreError::conflict(format!( + "{} is still being written or uploaded; try again once it has synced", + rel.display() + ))); + } + self.conflict_notified.lock().remove(&uid); + Ok(()) + } +} + +/// The path of `name` next to the file at `path`. +fn sibling_path(path: &str, name: &str) -> String { + match path.rsplit_once('/') { + Some((dir, _)) => format!("{dir}/{name}"), + None => name.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_original_sits_next_to_its_copy() { + assert_eq!( + sibling_path("a/b/f (sync-conflict 1).txt", "f.txt"), + "a/b/f.txt" + ); + assert_eq!(sibling_path("f (sync-conflict 1).txt", "f.txt"), "f.txt"); + } +} diff --git a/crates/pdfs-fuse/src/control.rs b/crates/pdfs-fuse/src/control.rs index 728d05f..7156f41 100644 --- a/crates/pdfs-fuse/src/control.rs +++ b/crates/pdfs-fuse/src/control.rs @@ -1003,6 +1003,17 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U Err(e) => CtlResponse::error(e), } } + Ok(CtlRequest::ListConflicts) => match core.list_conflicts() { + Ok(items) => CtlResponse::Conflicts { items }, + Err(e) => CtlResponse::error(e), + }, + Ok(CtlRequest::ResolveConflict { path, keep }) => match rel_to_mount(mountpoint, &path) { + Ok(rel) => match core.resolve_conflict(&rel, &keep) { + Ok(message) => CtlResponse::Ok { message }, + Err(e) => CtlResponse::error(e), + }, + Err(e) => CtlResponse::error(e), + }, Ok(CtlRequest::ListPendingOps) => match core.pending_op_infos() { Ok(items) => CtlResponse::PendingOps { items }, Err(e) => CtlResponse::error(e), diff --git a/crates/pdfs-fuse/src/lib.rs b/crates/pdfs-fuse/src/lib.rs index 4a4c531..296e812 100644 --- a/crates/pdfs-fuse/src/lib.rs +++ b/crates/pdfs-fuse/src/lib.rs @@ -80,6 +80,7 @@ use proton_drive_rs::{ mod albums; mod background; +mod conflicts; mod control; use control::run_control_socket; mod devices; diff --git a/crates/pdfs-fuse/src/sweep.rs b/crates/pdfs-fuse/src/sweep.rs index f3962b3..3ed5bb1 100644 --- a/crates/pdfs-fuse/src/sweep.rs +++ b/crates/pdfs-fuse/src/sweep.rs @@ -261,7 +261,7 @@ impl Core { /// is tracked in that fork's `State` — so consulting `self.state` alone /// answered "idle" for every such file and let the sweep delete one with a /// writer attached (`docs/BUGS.md` B74). Busy anywhere means busy. - fn is_busy(&self, uid: &NodeUid) -> bool { + pub(crate) fn is_busy(&self, uid: &NodeUid) -> bool { let mut busy = false; self.for_each_state(|st| { let Some(&ino) = st.by_uid.get(uid) else { diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs index 05ec48e..b74a312 100644 --- a/crates/pdfs-gui/src/app/main.rs +++ b/crates/pdfs-gui/src/app/main.rs @@ -47,11 +47,11 @@ use pdfs_core::auth; use pdfs_core::config::AppDirs; use pdfs_core::control::{ - ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, DeviceInfo, DirEntry, ErrorKind, - ImportSummary, InvitationInfo, JobItem, PendingOpInfo, PhotoItem, PhotoKind, PublicLinkInfo, - RefreshScope, Request, Response, RestorableFolder, RestoreItem, SearchHit, ShareEntry, - ShareEntryKind, SharedItem, SyncFolderInfo, SyncPhase, SyncProgress, ThumbnailBuildStatus, - TransferDirection, TransferItem, pending_summary, send, + ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, ConflictInfo, ConflictKeep, DeviceInfo, + DirEntry, ErrorKind, ImportSummary, InvitationInfo, JobItem, PendingOpInfo, PhotoItem, + PhotoKind, PublicLinkInfo, RefreshScope, Request, Response, RestorableFolder, RestoreItem, + SearchHit, ShareEntry, ShareEntryKind, SharedItem, SyncFolderInfo, SyncPhase, SyncProgress, + ThumbnailBuildStatus, TransferDirection, TransferItem, pending_summary, send, }; use pdfs_core::mounts::{MountAccess, MountKind, MountMode, MountSpec}; @@ -555,6 +555,13 @@ fn build_window(app: &adw::Application) { painted: RefCell::new(Vec::new()), inflight: Cell::new(false), }, + conflicts: ConflictsState { + group: locations_widgets.conflicts_group.clone(), + rows: RefCell::new(Vec::new()), + painted: RefCell::new(Vec::new()), + inflight: Cell::new(false), + fetched_at: Cell::new(None), + }, }, activity: ActivityState { content: activity_widgets.content.clone(), @@ -666,8 +673,12 @@ fn build_window(app: &adw::Application) { Some("shared") => load_shared(&ui_nav), Some("devices") if page_fresh(&ui_nav.devices.loaded_at) => {} Some("devices") => load_devices(&ui_nav), - Some("locations") if page_fresh(&ui_nav.locations.loaded_at) => {} - Some("locations") => load_locations(&ui_nav), + Some("locations") => { + refresh_conflicts(&ui_nav, true); + if !page_fresh(&ui_nav.locations.loaded_at) { + load_locations(&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. diff --git a/crates/pdfs-gui/src/app/pages/locations.rs b/crates/pdfs-gui/src/app/pages/locations.rs index 26c3ec5..1967035 100644 --- a/crates/pdfs-gui/src/app/pages/locations.rs +++ b/crates/pdfs-gui/src/app/pages/locations.rs @@ -24,6 +24,7 @@ pub(crate) struct LocationsState { pub(crate) loaded_at: Cell>, pub(crate) card: SyncCard, pub(crate) queue: QueueState, + pub(crate) conflicts: ConflictsState, } /// The status card heading the Sync page: what sync is doing, and Pause/Resume. @@ -49,6 +50,20 @@ pub(crate) struct QueueState { pub(crate) inflight: Cell, } +/// What a conflict row shows that can change: path, both sizes, both times. +pub(crate) type ConflictKey = (String, u64, i64, Option, Option); + +/// The "Conflicts" list: `(sync-conflict …)` copies waiting for a decision. +pub(crate) struct ConflictsState { + pub(crate) group: adw::PreferencesGroup, + pub(crate) rows: RefCell>, + pub(crate) painted: RefCell>, + pub(crate) inflight: Cell, + /// When the list was last fetched. Listing walks every node the daemon + /// knows, so the tick asks far less often than it does for the queue. + pub(crate) fetched_at: Cell>, +} + /// Widgets the Locations page's load/repaint touch. pub(crate) struct LocationsWidgets { pub(crate) content: gtk4::Stack, @@ -64,6 +79,7 @@ pub(crate) struct LocationsWidgets { pub(crate) pause: adw::SplitButton, pub(crate) queue_group: adw::PreferencesGroup, pub(crate) retry_all: gtk4::Button, + pub(crate) conflicts_group: adw::PreferencesGroup, } pub(crate) fn build_locations_page() -> (gtk4::Widget, LocationsWidgets) { @@ -80,6 +96,14 @@ pub(crate) fn build_locations_page() -> (gtk4::Widget, LocationsWidgets) { let transfers_group = build_transfers_group(); let (card, card_icon, card_row, pause) = build_sync_card(); let (queue_group, retry_all) = build_queue_group(); + let conflicts_group = adw::PreferencesGroup::builder() + .title("Conflicts") + .description( + "Files changed in two places at once. Both versions were kept; choose which \ + one stays.", + ) + .visible(false) + .build(); // Same warning the Computers page carried, for the same reason: the // on-demand switch removes the local copy, which is not a thing to discover @@ -95,6 +119,7 @@ pub(crate) fn build_locations_page() -> (gtk4::Widget, LocationsWidgets) { let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18); groups.append(&card); + groups.append(&conflicts_group); groups.append(&queue_group); groups.append(&transfers_group); groups.append(&group); @@ -157,6 +182,7 @@ pub(crate) fn build_locations_page() -> (gtk4::Widget, LocationsWidgets) { pause, queue_group, retry_all, + conflicts_group, }; (frame.upcast(), widgets) } @@ -474,6 +500,218 @@ fn retry_queued(ui: &Rc, id: Option) { }); } +/// How long a conflict listing stays fresh on the refresh tick. +const CONFLICTS_TTL: Duration = Duration::from_secs(30); + +/// Poll the conflict list while the Sync page is on screen. `force` skips the +/// TTL, for navigation and right after a resolution. +pub(crate) fn refresh_conflicts(ui: &Rc, force: bool) { + let conflicts = &ui.locations.conflicts; + if conflicts.inflight.get() + || (!force + && conflicts + .fetched_at + .get() + .is_some_and(|at| at.elapsed() < CONFLICTS_TTL)) + { + return; + } + conflicts.inflight.set(true); + let rx = spawn_request(ui.dirs.control_socket(), Request::ListConflicts); + let ui = ui.clone(); + glib::spawn_future_local(async move { + let result = rx.recv().await; + let conflicts = &ui.locations.conflicts; + conflicts.inflight.set(false); + conflicts.fetched_at.set(Some(Instant::now())); + match result { + Ok(Ok(Response::Conflicts { items })) => repaint_conflicts(&ui, &items), + // Same as the queue: an older daemon, or none, shows nothing. + _ => repaint_conflicts(&ui, &[]), + } + }); +} + +fn repaint_conflicts(ui: &Rc, items: &[ConflictInfo]) { + let conflicts = &ui.locations.conflicts; + let key: Vec = items + .iter() + .map(|c| { + ( + c.path.clone(), + c.size, + c.modified, + c.original_size, + c.original_modified, + ) + }) + .collect(); + if *conflicts.painted.borrow() == key { + return; + } + *conflicts.painted.borrow_mut() = key; + + for row in conflicts.rows.borrow_mut().drain(..) { + conflicts.group.remove(&row); + } + conflicts.group.set_visible(!items.is_empty()); + let mut rows = conflicts.rows.borrow_mut(); + for conflict in items { + let row = conflict_row(ui, conflict); + conflicts.group.add(&row); + rows.push(row); + } +} + +/// "12.3 MB, changed at 14:30": one side of a conflict. +fn conflict_side(size: u64, modified: i64) -> String { + format!( + "{}, changed {}", + glib::format_size(size), + clock_time(modified) + ) +} + +fn conflict_row(ui: &Rc, conflict: &ConflictInfo) -> adw::ActionRow { + let verdict = if !conflict.original_exists { + "The original is gone; only this copy is left".to_string() + } else if conflict.identical { + "Same content as the original".to_string() + } else { + format!("Differs from {}", file_name(&conflict.original_path)) + }; + let row = adw::ActionRow::builder() + .title(glib::markup_escape_text(&conflict.path).as_str()) + .subtitle(glib::markup_escape_text(&verdict).as_str()) + .title_lines(1) + .tooltip_text(&conflict.path) + .activatable(true) + .build(); + let image = gtk4::Image::from_icon_name("dialog-warning-symbolic"); + image.add_css_class("warning"); + row.add_prefix(&image); + row.add_suffix(>k4::Image::from_icon_name("go-next-symbolic")); + let ui = ui.clone(); + let conflict = conflict.clone(); + row.connect_activated(move |_| prompt_resolve_conflict(&ui, &conflict)); + row +} + +/// The last component of a mount-relative path. +fn file_name(path: &str) -> &str { + path.rsplit('/').next().unwrap_or(path) +} + +/// Ask which version of a conflicted file to keep. +fn prompt_resolve_conflict(ui: &Rc, conflict: &ConflictInfo) { + let win = ui_window(ui); + let copy_name = file_name(&conflict.path).to_string(); + let original_name = file_name(&conflict.original_path).to_string(); + let body = match (conflict.original_size, conflict.original_modified) { + (Some(size), Some(modified)) if conflict.original_exists => format!( + "“{original_name}” was changed here and elsewhere at the same time.\n\n\ + Original: {}\nCopy: {}\n\n\ + Whatever is not kept goes to Trash, where it can be restored.", + conflict_side(size, modified), + conflict_side(conflict.size, conflict.modified), + ), + _ => format!( + "The original “{original_name}” no longer exists; only the copy is left \ + ({}).\n\nKeep the copy to give it the original name back.", + conflict_side(conflict.size, conflict.modified), + ), + }; + let dialog = adw::AlertDialog::builder() + .heading("Resolve conflict") + .body(body) + .build(); + let group = adw::PreferencesGroup::new(); + let name_row = adw::EntryRow::builder() + .title("Name for the copy, if keeping both") + .build(); + name_row.set_text(©_name); + group.add(&name_row); + dialog.set_extra_child(Some(&group)); + dialog.add_response("cancel", "Cancel"); + if conflict.original_exists { + dialog.add_response("original", "Keep Original"); + dialog.add_response("both", "Keep Both"); + } + dialog.add_response("copy", "Keep Copy"); + dialog.set_default_response(Some("cancel")); + dialog.set_close_response("cancel"); + // Keeping both is a rename, so it needs a name that is neither side's. + let update_both = { + let dialog = dialog.clone(); + let copy_name = copy_name.clone(); + move |row: &adw::EntryRow| { + let name = row.text(); + let name = name.trim(); + dialog.set_response_enabled( + "both", + !name.is_empty() + && name != copy_name + && name != original_name + && !name.contains('/'), + ); + } + }; + if conflict.original_exists { + update_both(&name_row); + name_row.connect_changed(update_both); + } + let ui = ui.clone(); + let path = conflict.path.clone(); + dialog.connect_response(None, move |_, resp| { + let keep = match resp { + "original" => ConflictKeep::Original, + "copy" => ConflictKeep::Copy, + "both" => ConflictKeep::Both { + name: name_row.text().trim().to_string(), + }, + _ => return, + }; + resolve_conflict(&ui, path.clone(), keep); + }); + dialog.present(win.as_ref()); +} + +fn resolve_conflict(ui: &Rc, path: String, keep: ConflictKeep) { + ui.busy_begin(); + let rx = spawn_request( + ui.dirs.control_socket(), + Request::ResolveConflict { path, keep }, + ); + let ui = ui.clone(); + glib::spawn_future_local(async move { + let result = rx.recv().await; + ui.busy_end(); + match result { + Ok(Ok(Response::Ok { message })) => { + toast(&ui, &capitalize(&message)); + refresh_conflicts(&ui, true); + } + Ok(Ok(Response::Error { message, kind })) => { + toast_failure(&ui, "Couldn't resolve the conflict", &message, kind) + } + _ => toast_error( + &ui, + "Couldn't resolve the conflict", + "The mount service didn't respond.", + ), + } + }); +} + +/// First letter upper-cased, for a daemon message used as a sentence. +fn capitalize(message: &str) -> String { + let mut chars = message.chars(); + match chars.next() { + Some(first) => first.to_uppercase().chain(chars).collect(), + None => String::new(), + } +} + /// Show a status page in place of the locations list. pub(crate) fn locations_status( ui: &Rc, diff --git a/crates/pdfs-gui/src/app/pages/status.rs b/crates/pdfs-gui/src/app/pages/status.rs index 0cb6f02..8d5a0d3 100644 --- a/crates/pdfs-gui/src/app/pages/status.rs +++ b/crates/pdfs-gui/src/app/pages/status.rs @@ -618,6 +618,7 @@ pub(crate) fn refresh(ui: &Rc) { Some("locations") => { refresh_locations(ui); refresh_queue(ui); + refresh_conflicts(ui, false); } Some("activity") => refresh_activity(ui), _ => {} diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 9f80e75..abdf5ab 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -21,6 +21,11 @@ Schema: **30** (unchanged). - **See what is waiting to upload.** The Sync page lists the queue with each item's last error and next retry, with Retry now per item and Retry All; `pdfs sync queue` and `pdfs sync retry [id]` do the same from the CLI. Nothing in the queue is ever dropped from here. +- **Resolve sync conflicts.** `(sync-conflict …)` copies under My Files are listed on the Sync page + next to the file they conflict with, with sizes, times and whether the content differs. Keep the + original, keep the copy or keep both under a new name; whatever is not kept goes to Trash. + `pdfs conflicts` and `pdfs conflicts resolve --keep original|copy|both` do the same from + the CLI. Files that are open or still uploading are refused until they settle. ### Changed - **New app shell.** Every page has its own header bar; the sidebar ends in a footer with sync