diff --git a/crates/pdfs-cli/src/main.rs b/crates/pdfs-cli/src/main.rs index 4f33c1c..3270374 100644 --- a/crates/pdfs-cli/src/main.rs +++ b/crates/pdfs-cli/src/main.rs @@ -569,11 +569,18 @@ enum SyncCmd { Pause { /// End the pause by itself after this long, e.g. `30m`, `1h`, `2d`. /// Omit to pause until `pdfs sync resume`. - #[arg(long = "for", value_parser = parse_pause_duration)] + #[arg(long = "for", value_parser = parse_pause_duration, conflicts_with = "folder")] duration: Option, + /// Pause only this mirror folder (id from `sync list`) until resumed. + #[arg(long)] + folder: Option, }, /// Resume syncing after `pdfs sync pause`. - Resume, + Resume { + /// Resume only this folder, paused with `sync pause --folder`. + #[arg(long)] + folder: Option, + }, /// List uploads and changes not yet on Proton Drive. Queue, /// Retry a queued operation now (by id from `sync queue`), or every failed one. @@ -837,6 +844,10 @@ fn cmd_sync(action: SyncCmd) -> Result<()> { Some(pending) => format!("{} → {pending}", f.mode), None => f.mode.clone(), }; + let mode = match f.paused { + true => format!("{mode}, paused"), + false => mode, + }; println!( "[{}] {} ({mode}, {}, synced: {sync})", f.id, f.local_path, f.state @@ -873,7 +884,22 @@ fn cmd_sync(action: SyncCmd) -> Result<()> { })?)? } SyncCmd::Now { id } => ok_or_bail(control_request(CtlRequest::SyncNow { id })?)?, - SyncCmd::Pause { duration } => { + SyncCmd::Pause { + folder: Some(id), .. + } => ok_or_bail(control_request(CtlRequest::SetSyncFolderPaused { + id, + paused: true, + })?)?, + SyncCmd::Resume { folder: Some(id) } => { + ok_or_bail(control_request(CtlRequest::SetSyncFolderPaused { + id, + paused: false, + })?)? + } + SyncCmd::Pause { + duration, + folder: None, + } => { let until = duration.map(|secs| unix_now().saturating_add(secs as i64)); ok_or_bail(control_request(CtlRequest::SetSyncPaused { paused: true, @@ -882,10 +908,12 @@ fn cmd_sync(action: SyncCmd) -> Result<()> { } SyncCmd::Queue => return cmd_sync_queue(), SyncCmd::Retry { id } => ok_or_bail(control_request(CtlRequest::RetryPendingOp { id })?)?, - SyncCmd::Resume => ok_or_bail(control_request(CtlRequest::SetSyncPaused { - paused: false, - until: None, - })?)?, + SyncCmd::Resume { folder: None } => { + ok_or_bail(control_request(CtlRequest::SetSyncPaused { + paused: false, + until: None, + })?)? + } SyncCmd::Mode { id, mode } => { ok_or_bail(control_request(CtlRequest::SetSyncFolderMode { id, mode })?)? } diff --git a/crates/pdfs-core/src/control.rs b/crates/pdfs-core/src/control.rs index 0ae028c..a6fc0da 100644 --- a/crates/pdfs-core/src/control.rs +++ b/crates/pdfs-core/src/control.rs @@ -381,6 +381,9 @@ pub enum Request { /// Switch a synced folder between `mirror` and `ondemand` (Phase 3). Replies /// with [`Response::Ok`]. SetSyncFolderMode { id: i64, mode: String }, + /// Pause or resume one mirror folder's reconcile, independent of the + /// global pause. Persisted until resumed. Replies with [`Response::Ok`]. + SetSyncFolderPaused { id: i64, paused: bool }, /// Force a reconcile pass: one folder by id, or all when `id` is `None`. /// Replies with [`Response::Ok`]. SyncNow { id: Option }, @@ -724,6 +727,9 @@ pub struct SyncFolderInfo { /// running. Live daemon state, not a stored column. #[serde(default)] pub progress: Option, + /// The folder is paused on its own (see [`Request::SetSyncFolderPaused`]). + #[serde(default)] + pub paused: bool, } /// A folder under this machine's device offered for restore (in @@ -2409,6 +2415,10 @@ mod tests { id: 3, mode: "ondemand".into(), }, + Request::SetSyncFolderPaused { + id: 3, + paused: true, + }, Request::SyncNow { id: Some(3) }, Request::AdoptDevice { uid: Some("dev-1".into()), @@ -2530,6 +2540,7 @@ mod tests { pending_mode: None, mounted: true, progress: None, + paused: false, }, MountSpec { id: 2, @@ -2544,6 +2555,7 @@ mod tests { pending_mode: Some(MountMode::OnDemand), mounted: false, progress: None, + paused: false, }, MountSpec { id: 3, @@ -2565,6 +2577,7 @@ mod tests { total: 5, current: "report.pdf".into(), }), + paused: true, }, ], }; diff --git a/crates/pdfs-core/src/db/mounts.rs b/crates/pdfs-core/src/db/mounts.rs index 8860067..08f6865 100644 --- a/crates/pdfs-core/src/db/mounts.rs +++ b/crates/pdfs-core/src/db/mounts.rs @@ -171,6 +171,7 @@ impl Db { pending_mode: pending_mode.as_deref().map(MountMode::from), mounted: false, progress: None, + paused: false, }); } Ok(mounts) diff --git a/crates/pdfs-core/src/mounts.rs b/crates/pdfs-core/src/mounts.rs index 615ff00..bfa33df 100644 --- a/crates/pdfs-core/src/mounts.rs +++ b/crates/pdfs-core/src/mounts.rs @@ -110,6 +110,9 @@ pub struct MountSpec { /// Live device sync progress, absent when no pass is running. #[serde(default)] pub progress: Option, + /// A synced folder paused on its own. Live daemon state, not a stored column. + #[serde(default)] + pub paused: bool, } #[cfg(test)] diff --git a/crates/pdfs-fuse/src/control.rs b/crates/pdfs-fuse/src/control.rs index 7156f41..31a29f0 100644 --- a/crates/pdfs-fuse/src/control.rs +++ b/crates/pdfs-fuse/src/control.rs @@ -982,6 +982,17 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U Err(e) => CtlResponse::error(e), } } + Ok(CtlRequest::SetSyncFolderPaused { id, paused }) => { + match core.set_sync_folder_paused(id, paused) { + Ok(()) => CtlResponse::Ok { + message: match paused { + true => format!("folder {id} paused"), + false => format!("folder {id} resumed"), + }, + }, + Err(e) => CtlResponse::error(e), + } + } Ok(CtlRequest::SyncNow { id }) => { core.sync_now(id); CtlResponse::Ok { diff --git a/crates/pdfs-fuse/src/devices.rs b/crates/pdfs-fuse/src/devices.rs index ee89fe7..268497d 100644 --- a/crates/pdfs-fuse/src/devices.rs +++ b/crates/pdfs-fuse/src/devices.rs @@ -128,6 +128,7 @@ impl Core { location.mounted = self.states.is_mounted_at(Path::new(&location.local_path)); if let MountKind::Device { sync_folder_id } = &location.kind { location.progress = progress.get(sync_folder_id).cloned(); + location.paused = self.sync_folder_paused(*sync_folder_id); } } Ok(locations) @@ -448,7 +449,8 @@ impl Core { .into_iter() .map(|f| { let live = progress.get(&f.id).cloned(); - sync_folder_info(f, live) + let paused = self.sync_folder_paused(f.id); + sync_folder_info(f, live, paused) }) .collect()) } @@ -561,6 +563,7 @@ impl Core { return Err(CoreError::invalid(format!("no synced folder with id {id}"))); } drop(_guard); + self.clear_sync_folder_paused(id); // Stop watching the folder we just dropped. let _ = self.sync_tx.send(sync::SyncMsg::Rewatch); if delete_remote @@ -625,6 +628,13 @@ impl Core { .ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?; let current_mode = ApplicableMountMode::parse(&folder.mode) .map_err(|error| CoreError::invalid(format!("unsupported current mode: {error}")))?; + // A switch waits on the folder's reconcile, which a pause skips; refuse + // it up front rather than queue it indefinitely. + if current_mode != target_mode && self.sync_folder_paused(id) { + return Err(CoreError::invalid( + "this folder is paused; resume it before changing its mode", + )); + } if current_mode == target_mode { let mode_str = target_mode.as_str(); let lock = self.sync_lock(id); diff --git a/crates/pdfs-fuse/src/lib.rs b/crates/pdfs-fuse/src/lib.rs index 296e812..ab8e03f 100644 --- a/crates/pdfs-fuse/src/lib.rs +++ b/crates/pdfs-fuse/src/lib.rs @@ -5153,7 +5153,11 @@ fn evict_dir_contents(dir: &Path) -> std::io::Result<()> { /// Convert a stored synced folder into its wire form for the front-ends, with /// the live progress of its pass when one is running. -fn sync_folder_info(f: StoredSyncFolder, progress: Option) -> SyncFolderInfo { +fn sync_folder_info( + f: StoredSyncFolder, + progress: Option, + paused: bool, +) -> SyncFolderInfo { SyncFolderInfo { id: f.id, local_path: f.local_path, @@ -5163,6 +5167,7 @@ fn sync_folder_info(f: StoredSyncFolder, progress: Option) -> Sync state: f.state, last_sync: f.last_sync, progress, + paused, } } diff --git a/crates/pdfs-fuse/src/pause.rs b/crates/pdfs-fuse/src/pause.rs index 7800fa5..8c09c97 100644 --- a/crates/pdfs-fuse/src/pause.rs +++ b/crates/pdfs-fuse/src/pause.rs @@ -10,12 +10,44 @@ //! it. A timed pause ends by itself; [`Core::set_sync_paused`] arms a timer for //! that, and [`Core::sync_paused`] compares against the clock anyway, so a //! deadline that passed while the daemon was down is already over on mount. +//! +//! A single mirror folder can be paused on its own as well: its reconcile is +//! skipped, and with it any mode switch queued behind that reconcile, until the +//! folder is resumed. On-demand folders cannot be paused alone — their writes +//! go through the shared queue, which only the global pause holds back. use super::*; /// The `state` key holding the resume time, in Unix seconds. const PAUSED_UNTIL_KEY: &str = "sync_paused_until"; +/// The `state` key prefix marking one synced folder paused; its id follows. +const FOLDER_PAUSED_PREFIX: &str = "sync_folder_paused:"; + +fn folder_paused_key(id: i64) -> String { + format!("{FOLDER_PAUSED_PREFIX}{id}") +} + +/// Whether synced folder `id` is paused on its own. A row that cannot be read +/// counts as running, like the global pause. +fn load_folder_paused(db: &Db, id: i64) -> bool { + match db.state_i64(&folder_paused_key(id)) { + Ok(value) => value.is_some_and(|v| v != 0), + Err(error) => { + warn!(id, %error, "reading a folder pause failed; treating it as running"); + false + } + } +} + +/// Persist synced folder `id`'s own pause, or clear it. +fn store_folder_paused(db: &Db, id: i64, paused: bool) -> pdfs_core::Result<()> { + match paused { + true => db.set_state_i64(&folder_paused_key(id), 1), + false => db.clear_state(&folder_paused_key(id)), + } +} + /// A pause with no end time: it lasts until the user resumes. pub(crate) const PAUSED_INDEFINITELY: i64 = i64::MAX; @@ -95,6 +127,48 @@ impl Core { Ok(()) } + /// True while the user has this one synced folder paused. A row that cannot + /// be read counts as running, like the global pause. + pub(crate) fn sync_folder_paused(&self, id: i64) -> bool { + load_folder_paused(&self.db, id) + } + + /// Pause or resume one mirror folder's reconcile. Resuming reconciles it + /// straight away to catch up on what the watcher saw meanwhile. + pub(crate) fn set_sync_folder_paused(&self, id: i64, paused: bool) -> Result<(), CoreError> { + let folder = self + .db + .sync_folder_get(id) + .map_err(|e| CoreError::internal(format!("db: {e:?}")))? + .ok_or_else(|| CoreError::not_found(format!("no synced folder with id {id}")))?; + if paused && folder.mode != "mirror" { + return Err(CoreError::invalid( + "only a mirrored folder can be paused on its own; an on-demand folder uploads \ + through the shared queue, so pause all sync instead", + )); + } + if let Err(error) = store_folder_paused(&self.db, id, paused) { + return Err(CoreError::internal(format!( + "saving the folder pause: {error}" + ))); + } + if paused { + info!(id, path = %folder.local_path, "folder sync paused"); + } else { + info!(id, path = %folder.local_path, "folder sync resumed"); + self.sync_now(Some(id)); + } + Ok(()) + } + + /// Forget a removed folder's pause, so a later folder reusing its id does + /// not start out paused. + pub(crate) fn clear_sync_folder_paused(&self, id: i64) { + if let Err(error) = store_folder_paused(&self.db, id, false) { + warn!(id, %error, "clearing a removed folder's pause failed"); + } + } + /// Wake everything a pause held back: the drain worker, and a full /// reconcile to pick up whatever the watcher saw meanwhile. fn resume_sync_work(&self) { @@ -146,4 +220,16 @@ mod tests { assert!(!paused_at(200, 300)); assert!(paused_at(PAUSED_INDEFINITELY, i64::MAX - 1)); } + + #[test] + fn a_folder_pause_is_kept_per_folder_until_cleared() { + let db = Db::open(std::path::Path::new(":memory:")).unwrap(); + assert!(!load_folder_paused(&db, 3)); + store_folder_paused(&db, 3, true).unwrap(); + assert!(load_folder_paused(&db, 3)); + assert!(!load_folder_paused(&db, 4)); + assert!(!load_folder_paused(&db, 33)); + store_folder_paused(&db, 3, false).unwrap(); + assert!(!load_folder_paused(&db, 3)); + } } diff --git a/crates/pdfs-fuse/src/sync.rs b/crates/pdfs-fuse/src/sync.rs index fa12058..e1257ea 100644 --- a/crates/pdfs-fuse/src/sync.rs +++ b/crates/pdfs-fuse/src/sync.rs @@ -82,6 +82,12 @@ impl Core { /// is guaranteed — a switch requested over the control socket cannot wait for a /// pass that may run for minutes. pub(crate) fn reconcile_folder(&self, folder: &StoredSyncFolder) { + // A paused folder skips the settle too: a queued switch to on-demand + // evicts the local tree, which is only safe after a pass uploaded it. + if self.sync_folder_paused(folder.id) { + debug!(id = folder.id, "sync: folder paused, skipping reconcile"); + return; + } // `ondemand` folders are live FUSE mounts, not mirrored, so there is nothing // to reconcile — but one may still carry a queued switch back to `mirror`, // so the settle below is not skipped with it. diff --git a/crates/pdfs-gui/src/app/pages/devices.rs b/crates/pdfs-gui/src/app/pages/devices.rs index 6720d0e..b9791d8 100644 --- a/crates/pdfs-gui/src/app/pages/devices.rs +++ b/crates/pdfs-gui/src/app/pages/devices.rs @@ -839,6 +839,7 @@ mod tests { last_sync: 0, pending_mode: None, progress: None, + paused: false, } } diff --git a/crates/pdfs-gui/src/app/pages/locations.rs b/crates/pdfs-gui/src/app/pages/locations.rs index 1967035..6b251d3 100644 --- a/crates/pdfs-gui/src/app/pages/locations.rs +++ b/crates/pdfs-gui/src/app/pages/locations.rs @@ -915,10 +915,30 @@ fn add_my_files_controls(ui: &Rc, row: &adw::ActionRow) { row.add_suffix(&change); } -/// Sync now (mirror only), the on-demand switch, and Stop syncing for one -/// synced folder. +/// Pause or resume and Sync now (mirror only), the on-demand switch, and Stop +/// syncing for one synced folder. fn add_device_controls(ui: &Rc, row: &adw::ActionRow, spec: &MountSpec, id: i64) { if spec.mode != MountMode::OnDemand { + let (icon, tooltip) = match spec.paused { + true => ( + "media-playback-start-symbolic", + "Resume syncing this folder", + ), + false => ("media-playback-pause-symbolic", "Pause syncing this folder"), + }; + let pause = gtk4::Button::builder() + .icon_name(icon) + .tooltip_text(tooltip) + .valign(gtk4::Align::Center) + .build(); + pause.add_css_class("flat"); + let ui_pause = ui.clone(); + let paused = spec.paused; + pause.connect_clicked(move |_| set_folder_paused(&ui_pause, id, !paused)); + row.add_suffix(&pause); + } + // A paused folder skips its passes, so Sync now would do nothing. + if spec.mode != MountMode::OnDemand && !spec.paused { let sync_now = gtk4::Button::builder() .icon_name("view-refresh-symbolic") .tooltip_text("Sync this folder now") @@ -943,6 +963,8 @@ fn add_device_controls(ui: &Rc, row: &adw::ActionRow, spec: &MountSpec, id: ) .valign(gtk4::Align::Center) .active(target == MountMode::OnDemand) + // The daemon refuses a mode switch on a paused folder. + .sensitive(!spec.paused) .build(); let ui_mode = ui.clone(); let mode_path = spec.local_path.clone(); @@ -1002,6 +1024,33 @@ fn confirm_ondemand(ui: &Rc, id: i64, path: &str) { dialog.present(win.as_ref()); } +/// Pause or resume one synced folder, then repaint so its row shows the result. +fn set_folder_paused(ui: &Rc, id: i64, paused: bool) { + let rx = spawn_request( + ui.dirs.control_socket(), + Request::SetSyncFolderPaused { id, paused }, + ); + let ui = ui.clone(); + glib::spawn_future_local(async move { + let failed = match paused { + true => "Couldn't pause the folder", + false => "Couldn't resume the folder", + }; + match rx.recv().await { + Ok(Ok(Response::Ok { .. })) => toast( + &ui, + match paused { + true => "Folder paused", + false => "Folder resumed", + }, + ), + Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, failed, &message, kind), + _ => toast_error(&ui, failed, "The mount service didn't respond."), + } + refresh_locations(&ui); + }); +} + /// Ask the daemon for an immediate pass over one folder. fn sync_folder_now(ui: &Rc, id: i64) { let rx = spawn_request(ui.dirs.control_socket(), Request::SyncNow { id: Some(id) }); @@ -1049,6 +1098,7 @@ pub(crate) fn location_subtitle(spec: &MountSpec) -> String { if matches!(spec.kind, MountKind::Device { .. }) { parts.push(match &spec.progress { Some(p) => sync_progress_label(p), + None if spec.paused => "paused".to_string(), None => sync_state_label(&spec.state).to_string(), }); } @@ -1081,6 +1131,7 @@ mod tests { pending_mode: None, mounted, progress: None, + paused: false, } } @@ -1096,6 +1147,17 @@ mod tests { assert_eq!(location_subtitle(&spec), "Synced · up to date"); } + #[test] + fn a_paused_folder_says_paused_instead_of_its_state() { + let mut spec = spec( + MountKind::Device { sync_folder_id: 7 }, + MountMode::Mirror, + false, + ); + spec.paused = true; + assert_eq!(location_subtitle(&spec), "Synced · paused"); + } + #[test] fn an_on_demand_folder_without_a_session_says_so() { let spec = spec( diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index e685db9..c53260c 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -26,6 +26,11 @@ Schema: **30** (unchanged). 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. +- **Pause one folder.** A mirrored folder on the Sync page has its own pause button; while paused + it is not reconciled in either direction and its mode cannot be switched, and resuming syncs it + straight away. `pdfs sync pause --folder ` / `pdfs sync resume --folder ` do the same, and + `pdfs sync list` marks paused folders. The pause is kept across restarts. On-demand folders + upload through the shared queue, so only the global pause holds them back. ### Changed - **New app shell.** Every page has its own header bar; the sidebar ends in a footer with sync