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),
_ => {}