feat(core,fuse,gui,cli): list and resolve sync conflicts (phase 3)

Conflict copies under My Files are paired with their original and listed
over the control socket. The Sync page shows them with a resolve dialog
(keep original, keep copy, keep both under a new name), and the CLI gains
`pdfs conflicts` and `pdfs conflicts resolve`. Every resolution goes
through the Proton trash, and files that are open, being written or still
owed an upload are refused.
This commit is contained in:
nvrl committed 2026-09-23 02:14:56 +02:00
1 parent 067561564e
commit 792eee75a4
10 files changed
+536 -10

No files matched your search

+66 -2
View File
@@ -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<ConflictCmd>,
},
/// 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<String>,
},
}
#[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<ConflictCmd>) -> 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)?;
+39
View File
@@ -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<i64> },
/// 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<u64>,
pub original_modified: Option<i64>,
/// 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<SyncFolderInfo> },
/// The upload/change queue (reply to [`Request::ListPendingOps`]).
PendingOps { items: Vec<PendingOpInfo> },
/// Conflict copies (reply to [`Request::ListConflicts`]).
Conflicts { items: Vec<ConflictInfo> },
/// Folders offered for restore (reply to [`Request::ListRestorableFolders`]).
RestorableFolders { items: Vec<RestorableFolder> },
/// A node's share: members + pending invitations, and its public link if any
+156
View File
@@ -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<Vec<ConflictInfo>> {
let stored = self
.db
.load_all()
.map_err(|e| CoreError::internal(format!("listing conflicts: {e:?}")))?;
let nodes: Vec<Node> = 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<String> {
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");
}
}
+11
View File
@@ -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),
+1
View File
@@ -80,6 +80,7 @@ use proton_drive_rs::{
mod albums;
mod background;
mod conflicts;
mod control;
use control::run_control_socket;
mod devices;
+1 -1
View File
@@ -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 {
+18 -7
View File
@@ -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.
+238
View File
@@ -24,6 +24,7 @@ pub(crate) struct LocationsState {
pub(crate) loaded_at: Cell<Option<Instant>>,
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<bool>,
}
/// What a conflict row shows that can change: path, both sizes, both times.
pub(crate) type ConflictKey = (String, u64, i64, Option<u64>, Option<i64>);
/// The "Conflicts" list: `(sync-conflict …)` copies waiting for a decision.
pub(crate) struct ConflictsState {
pub(crate) group: adw::PreferencesGroup,
pub(crate) rows: RefCell<Vec<adw::ActionRow>>,
pub(crate) painted: RefCell<Vec<ConflictKey>>,
pub(crate) inflight: Cell<bool>,
/// 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<Option<Instant>>,
}
/// 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<Ui>, id: Option<i64>) {
});
}
/// 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<Ui>, 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<Ui>, items: &[ConflictInfo]) {
let conflicts = &ui.locations.conflicts;
let key: Vec<ConflictKey> = 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<Ui>, 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(&gtk4::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<Ui>, 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(&copy_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<Ui>, 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<Ui>,
+1
View File
@@ -618,6 +618,7 @@ pub(crate) fn refresh(ui: &Rc<Ui>) {
Some("locations") => {
refresh_locations(ui);
refresh_queue(ui);
refresh_conflicts(ui, false);
}
Some("activity") => refresh_activity(ui),
_ => {}
+5
View File
@@ -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 <path> --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