mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 00:47:00 +01:00
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:
1 parent
067561564e
commit
792eee75a4
10 files changed
+536
-10
No files matched your search
@@ -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)?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -80,6 +80,7 @@ use proton_drive_rs::{
|
||||
|
||||
mod albums;
|
||||
mod background;
|
||||
mod conflicts;
|
||||
mod control;
|
||||
use control::run_control_socket;
|
||||
mod devices;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(>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<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(©_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>,
|
||||
|
||||
@@ -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),
|
||||
_ => {}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user