mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
feat(core,fuse,gui,cli): pause a single mirrored folder (phase 6)
This commit is contained in:
1 parent
a86c3e586d
commit
b58b60010e
12 files changed
+242
-11
No files matched your search
@@ -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<u64>,
|
||||
/// Pause only this mirror folder (id from `sync list`) until resumed.
|
||||
#[arg(long)]
|
||||
folder: Option<i64>,
|
||||
},
|
||||
/// Resume syncing after `pdfs sync pause`.
|
||||
Resume,
|
||||
Resume {
|
||||
/// Resume only this folder, paused with `sync pause --folder`.
|
||||
#[arg(long)]
|
||||
folder: Option<i64>,
|
||||
},
|
||||
/// 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 })?)?
|
||||
}
|
||||
|
||||
@@ -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<i64> },
|
||||
@@ -724,6 +727,9 @@ pub struct SyncFolderInfo {
|
||||
/// running. Live daemon state, not a stored column.
|
||||
#[serde(default)]
|
||||
pub progress: Option<SyncProgress>,
|
||||
/// 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -171,6 +171,7 @@ impl Db {
|
||||
pending_mode: pending_mode.as_deref().map(MountMode::from),
|
||||
mounted: false,
|
||||
progress: None,
|
||||
paused: false,
|
||||
});
|
||||
}
|
||||
Ok(mounts)
|
||||
|
||||
@@ -110,6 +110,9 @@ pub struct MountSpec {
|
||||
/// Live device sync progress, absent when no pass is running.
|
||||
#[serde(default)]
|
||||
pub progress: Option<SyncProgress>,
|
||||
/// A synced folder paused on its own. Live daemon state, not a stored column.
|
||||
#[serde(default)]
|
||||
pub paused: bool,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<SyncProgress>) -> SyncFolderInfo {
|
||||
fn sync_folder_info(
|
||||
f: StoredSyncFolder,
|
||||
progress: Option<SyncProgress>,
|
||||
paused: bool,
|
||||
) -> SyncFolderInfo {
|
||||
SyncFolderInfo {
|
||||
id: f.id,
|
||||
local_path: f.local_path,
|
||||
@@ -5163,6 +5167,7 @@ fn sync_folder_info(f: StoredSyncFolder, progress: Option<SyncProgress>) -> Sync
|
||||
state: f.state,
|
||||
last_sync: f.last_sync,
|
||||
progress,
|
||||
paused,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -839,6 +839,7 @@ mod tests {
|
||||
last_sync: 0,
|
||||
pending_mode: None,
|
||||
progress: None,
|
||||
paused: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -915,10 +915,30 @@ fn add_my_files_controls(ui: &Rc<Ui>, 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<Ui>, 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<Ui>, 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<Ui>, 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<Ui>, 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<Ui>, 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(
|
||||
|
||||
Reference in new issue
Block a user