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/resume sync and a visible upload queue (phase 2)
Backend (docs/UI_UX_PLAN.md B-1, B-2):
- Request::SetSyncPaused { paused, until } pauses the drain worker and the
mirror reconcile. FUSE reads, write staging, the watcher and the poll keep
running. The pause is persisted in the `state` table (`sync_paused_until`),
so it survives a restart; a timed pause ends by itself through a timer
thread, re-armed on mount. Resuming wakes the drain and runs a full
reconcile. Response::Status gains `paused` and `paused_until`.
- Request::ListPendingOps lists the queue with paths, attempts, last error,
next attempt and parked/failing flags. Request::RetryPendingOp { id } makes
one backed-off op (or every failed one) due now. Parked ops are never
un-parked and nothing is dropped from the queue.
Front ends:
- Sync page: a status card with Pause/Resume (split button with 1 h, 8 h and
24 h pauses) and a "Waiting to Upload" list with Retry now and Retry All.
- The sidebar strip and the tray show the paused state; the tray gains Pause
Sync / Resume Sync and names failing changes.
- CLI: `pdfs sync pause [--for 30m|1h|2d]`, `pdfs sync resume`,
`pdfs sync queue`, `pdfs sync retry [id]`; `pdfs status` reports a pause.
Not in this phase: merging Activity into the Sync page, discarding a queued
op (needs a recovery copy first), and per-folder pause.
This commit is contained in:
1 parent
cf9e1665ca
commit
067561564e
17 files changed
+995
-13
No files matched your search
@@ -48,10 +48,10 @@ use pdfs_core::config::AppDirs;
|
||||
|
||||
use pdfs_core::control::{
|
||||
ActivityEntry, ActivityKind, AlbumInfo, BookmarkInfo, DeviceInfo, DirEntry, ErrorKind,
|
||||
ImportSummary, InvitationInfo, JobItem, PhotoItem, PhotoKind, PublicLinkInfo, RefreshScope,
|
||||
Request, Response, RestorableFolder, RestoreItem, SearchHit, ShareEntry, ShareEntryKind,
|
||||
SharedItem, SyncFolderInfo, SyncPhase, SyncProgress, ThumbnailBuildStatus, TransferDirection,
|
||||
TransferItem, pending_summary, send,
|
||||
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};
|
||||
@@ -542,6 +542,19 @@ fn build_window(app: &adw::Application) {
|
||||
rows: RefCell::new(Vec::new()),
|
||||
inflight: Cell::new(false),
|
||||
loaded_at: Cell::new(None),
|
||||
card: SyncCard {
|
||||
icon: locations_widgets.card_icon.clone(),
|
||||
row: locations_widgets.card_row.clone(),
|
||||
pause: locations_widgets.pause.clone(),
|
||||
paused: Cell::new(false),
|
||||
},
|
||||
queue: QueueState {
|
||||
group: locations_widgets.queue_group.clone(),
|
||||
retry_all: locations_widgets.retry_all.clone(),
|
||||
rows: RefCell::new(Vec::new()),
|
||||
painted: RefCell::new(Vec::new()),
|
||||
inflight: Cell::new(false),
|
||||
},
|
||||
},
|
||||
activity: ActivityState {
|
||||
content: activity_widgets.content.clone(),
|
||||
|
||||
@@ -22,6 +22,31 @@ pub(crate) struct LocationsState {
|
||||
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
|
||||
pub(crate) inflight: Cell<bool>,
|
||||
pub(crate) loaded_at: Cell<Option<Instant>>,
|
||||
pub(crate) card: SyncCard,
|
||||
pub(crate) queue: QueueState,
|
||||
}
|
||||
|
||||
/// The status card heading the Sync page: what sync is doing, and Pause/Resume.
|
||||
pub(crate) struct SyncCard {
|
||||
pub(crate) icon: gtk4::Image,
|
||||
pub(crate) row: adw::ActionRow,
|
||||
pub(crate) pause: adw::SplitButton,
|
||||
/// Whether the last status said paused, so the button knows which way to go.
|
||||
pub(crate) paused: Cell<bool>,
|
||||
}
|
||||
|
||||
/// What a queue row shows that can change: id, attempts, next attempt, parked.
|
||||
pub(crate) type QueueKey = (i64, i64, Option<i64>, bool);
|
||||
|
||||
/// The "Waiting to Upload" list: the daemon's pending-op queue.
|
||||
pub(crate) struct QueueState {
|
||||
pub(crate) group: adw::PreferencesGroup,
|
||||
pub(crate) retry_all: gtk4::Button,
|
||||
pub(crate) rows: RefCell<Vec<adw::ActionRow>>,
|
||||
/// What the rows were built from, so an unchanged queue is not rebuilt on
|
||||
/// every tick.
|
||||
pub(crate) painted: RefCell<Vec<QueueKey>>,
|
||||
pub(crate) inflight: Cell<bool>,
|
||||
}
|
||||
|
||||
/// Widgets the Locations page's load/repaint touch.
|
||||
@@ -34,6 +59,11 @@ pub(crate) struct LocationsWidgets {
|
||||
pub(crate) add_folder: gtk4::Button,
|
||||
/// Live transfers, above the folder list; painted by the refresh loop.
|
||||
pub(crate) transfers_group: adw::PreferencesGroup,
|
||||
pub(crate) card_icon: gtk4::Image,
|
||||
pub(crate) card_row: adw::ActionRow,
|
||||
pub(crate) pause: adw::SplitButton,
|
||||
pub(crate) queue_group: adw::PreferencesGroup,
|
||||
pub(crate) retry_all: gtk4::Button,
|
||||
}
|
||||
|
||||
pub(crate) fn build_locations_page() -> (gtk4::Widget, LocationsWidgets) {
|
||||
@@ -48,6 +78,8 @@ pub(crate) fn build_locations_page() -> (gtk4::Widget, LocationsWidgets) {
|
||||
.build();
|
||||
let refresh = refresh_button();
|
||||
let transfers_group = build_transfers_group();
|
||||
let (card, card_icon, card_row, pause) = build_sync_card();
|
||||
let (queue_group, retry_all) = build_queue_group();
|
||||
|
||||
// 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
|
||||
@@ -62,6 +94,8 @@ pub(crate) fn build_locations_page() -> (gtk4::Widget, LocationsWidgets) {
|
||||
.build();
|
||||
|
||||
let groups = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
|
||||
groups.append(&card);
|
||||
groups.append(&queue_group);
|
||||
groups.append(&transfers_group);
|
||||
groups.append(&group);
|
||||
let clamp = adw::Clamp::builder().child(&groups).build();
|
||||
@@ -118,6 +152,11 @@ pub(crate) fn build_locations_page() -> (gtk4::Widget, LocationsWidgets) {
|
||||
refresh,
|
||||
add_folder,
|
||||
transfers_group,
|
||||
card_icon,
|
||||
card_row,
|
||||
pause,
|
||||
queue_group,
|
||||
retry_all,
|
||||
};
|
||||
(frame.upcast(), widgets)
|
||||
}
|
||||
@@ -130,6 +169,309 @@ pub(crate) fn wire_locations(ui: &Rc<Ui>, retry: >k4::Button, add_folder: >k
|
||||
});
|
||||
let ui_add = ui.clone();
|
||||
add_folder.connect_clicked(move |_| prompt_add_sync_folder(&ui_add));
|
||||
wire_pause(ui);
|
||||
let ui_retry = ui.clone();
|
||||
ui.locations
|
||||
.queue
|
||||
.retry_all
|
||||
.connect_clicked(move |_| retry_queued(&ui_retry, None));
|
||||
}
|
||||
|
||||
/// The status card: a large state icon, the state in words, and Pause/Resume
|
||||
/// with timed pauses in its menu.
|
||||
fn build_sync_card() -> (
|
||||
adw::PreferencesGroup,
|
||||
gtk4::Image,
|
||||
adw::ActionRow,
|
||||
adw::SplitButton,
|
||||
) {
|
||||
let icon = gtk4::Image::builder()
|
||||
.icon_name("emblem-synchronizing-symbolic")
|
||||
.pixel_size(32)
|
||||
.margin_top(6)
|
||||
.margin_bottom(6)
|
||||
.build();
|
||||
icon.add_css_class("sidebar-status");
|
||||
let menu = gio::Menu::new();
|
||||
menu.append(Some("Pause for 1 Hour"), Some("sync.pause-for(int64 3600)"));
|
||||
menu.append(
|
||||
Some("Pause for 8 Hours"),
|
||||
Some("sync.pause-for(int64 28800)"),
|
||||
);
|
||||
menu.append(
|
||||
Some("Pause for 24 Hours"),
|
||||
Some("sync.pause-for(int64 86400)"),
|
||||
);
|
||||
let pause = adw::SplitButton::builder()
|
||||
.label("Pause")
|
||||
.menu_model(&menu)
|
||||
.valign(gtk4::Align::Center)
|
||||
.tooltip_text("Stop uploading until you resume. Files still open as usual.")
|
||||
.dropdown_tooltip("Pause for a while")
|
||||
.build();
|
||||
let row = adw::ActionRow::builder()
|
||||
.title("Checking…")
|
||||
.title_lines(1)
|
||||
.subtitle_lines(2)
|
||||
.build();
|
||||
row.add_css_class("property");
|
||||
row.add_prefix(&icon);
|
||||
row.add_suffix(&pause);
|
||||
let group = adw::PreferencesGroup::new();
|
||||
group.add(&row);
|
||||
(group, icon, row, pause)
|
||||
}
|
||||
|
||||
/// Paint the Sync page's status card from what the sidebar strip shows.
|
||||
pub(crate) fn paint_sync_card(
|
||||
ui: &Rc<Ui>,
|
||||
icon: &str,
|
||||
class: Option<&str>,
|
||||
title: &str,
|
||||
detail: Option<&str>,
|
||||
paused: bool,
|
||||
connected: bool,
|
||||
) {
|
||||
let card = &ui.locations.card;
|
||||
card.icon.set_icon_name(Some(icon));
|
||||
for c in ["success", "warning", "error"] {
|
||||
card.icon.remove_css_class(c);
|
||||
}
|
||||
if let Some(class) = class {
|
||||
card.icon.add_css_class(class);
|
||||
}
|
||||
card.row.set_title(title);
|
||||
card.row.set_subtitle(detail.unwrap_or_default());
|
||||
card.paused.set(paused);
|
||||
card.pause
|
||||
.set_label(if paused { "Resume" } else { "Pause" });
|
||||
card.pause.set_tooltip_text(Some(if paused {
|
||||
"Upload everything that waited while sync was paused"
|
||||
} else {
|
||||
"Stop uploading until you resume. Files still open as usual."
|
||||
}));
|
||||
if paused {
|
||||
card.pause.add_css_class("suggested-action");
|
||||
} else {
|
||||
card.pause.remove_css_class("suggested-action");
|
||||
}
|
||||
card.pause.set_sensitive(connected);
|
||||
}
|
||||
|
||||
/// Pause/Resume on the card: the button flips, the menu pauses for a while.
|
||||
fn wire_pause(ui: &Rc<Ui>) {
|
||||
let ui_click = ui.clone();
|
||||
ui.locations.card.pause.connect_clicked(move |_| {
|
||||
let resume = ui_click.locations.card.paused.get();
|
||||
set_sync_paused(&ui_click, !resume, None);
|
||||
});
|
||||
let actions = gio::SimpleActionGroup::new();
|
||||
let pause_for = gio::SimpleAction::new("pause-for", Some(glib::VariantTy::INT64));
|
||||
let ui_for = ui.clone();
|
||||
pause_for.connect_activate(move |_, param| {
|
||||
let Some(secs) = param.and_then(|p| p.get::<i64>()) else {
|
||||
return;
|
||||
};
|
||||
let until = glib::DateTime::now_utc()
|
||||
.map(|now| now.to_unix())
|
||||
.unwrap_or_default()
|
||||
+ secs;
|
||||
set_sync_paused(&ui_for, true, Some(until));
|
||||
});
|
||||
actions.add_action(&pause_for);
|
||||
ui.locations
|
||||
.card
|
||||
.pause
|
||||
.insert_action_group("sync", Some(&actions));
|
||||
}
|
||||
|
||||
fn set_sync_paused(ui: &Rc<Ui>, paused: bool, until: Option<i64>) {
|
||||
ui.locations.card.pause.set_sensitive(false);
|
||||
let rx = spawn_request(
|
||||
ui.dirs.control_socket(),
|
||||
Request::SetSyncPaused { paused, until },
|
||||
);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let what = if paused {
|
||||
"Couldn't pause sync"
|
||||
} else {
|
||||
"Couldn't resume sync"
|
||||
};
|
||||
match rx.recv().await {
|
||||
Ok(Ok(Response::Ok { .. })) => toast(
|
||||
&ui,
|
||||
match (paused, until) {
|
||||
(false, _) => "Sync resumed",
|
||||
(true, None) => "Sync paused until you resume",
|
||||
(true, Some(_)) => "Sync paused",
|
||||
},
|
||||
),
|
||||
Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, what, &message, kind),
|
||||
_ => toast_error(&ui, what, "The mount service didn't respond."),
|
||||
}
|
||||
ui.locations.card.pause.set_sensitive(true);
|
||||
refresh_status(&ui);
|
||||
});
|
||||
}
|
||||
|
||||
/// The queue list, hidden while nothing waits.
|
||||
fn build_queue_group() -> (adw::PreferencesGroup, gtk4::Button) {
|
||||
let retry_all = gtk4::Button::builder()
|
||||
.label("Retry All")
|
||||
.valign(gtk4::Align::Center)
|
||||
.tooltip_text("Try every failed change again now")
|
||||
.visible(false)
|
||||
.build();
|
||||
retry_all.add_css_class("flat");
|
||||
let group = adw::PreferencesGroup::builder()
|
||||
.title("Waiting to Upload")
|
||||
.description("Changes made on this computer that are not on Proton Drive yet.")
|
||||
.header_suffix(&retry_all)
|
||||
.visible(false)
|
||||
.build();
|
||||
(group, retry_all)
|
||||
}
|
||||
|
||||
/// Most queue rows shown at once. The queue can hold thousands after a big copy;
|
||||
/// the first screenful says what is going on, and the count says the rest.
|
||||
const QUEUE_ROWS_SHOWN: usize = 50;
|
||||
|
||||
/// Poll the pending-op queue while the Sync page is on screen.
|
||||
pub(crate) fn refresh_queue(ui: &Rc<Ui>) {
|
||||
if ui.locations.queue.inflight.get() {
|
||||
return;
|
||||
}
|
||||
ui.locations.queue.inflight.set(true);
|
||||
let rx = spawn_request(ui.dirs.control_socket(), Request::ListPendingOps);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
ui.locations.queue.inflight.set(false);
|
||||
match result {
|
||||
Ok(Ok(Response::PendingOps { items })) => repaint_queue(&ui, &items),
|
||||
// An older daemon without the request, or none at all: say nothing
|
||||
// rather than show a list that cannot be kept current.
|
||||
_ => repaint_queue(&ui, &[]),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn repaint_queue(ui: &Rc<Ui>, items: &[PendingOpInfo]) {
|
||||
let queue = &ui.locations.queue;
|
||||
// Stuck first — they are why the user is here — then oldest first.
|
||||
let mut items: Vec<&PendingOpInfo> = items.iter().collect();
|
||||
items.sort_by_key(|op| (!op.failing, op.attempts == 0, op.id));
|
||||
let key: Vec<QueueKey> = items
|
||||
.iter()
|
||||
.map(|op| (op.id, op.attempts, op.next_attempt_at, op.parked))
|
||||
.collect();
|
||||
if *queue.painted.borrow() == key {
|
||||
return;
|
||||
}
|
||||
*queue.painted.borrow_mut() = key;
|
||||
|
||||
for row in queue.rows.borrow_mut().drain(..) {
|
||||
queue.group.remove(&row);
|
||||
}
|
||||
queue.group.set_visible(!items.is_empty());
|
||||
queue
|
||||
.retry_all
|
||||
.set_visible(items.iter().any(|op| op.attempts > 0 && !op.parked));
|
||||
let hidden = items.len().saturating_sub(QUEUE_ROWS_SHOWN);
|
||||
queue.group.set_description(Some(&if hidden > 0 {
|
||||
format!(
|
||||
"Changes made on this computer that are not on Proton Drive yet. \
|
||||
Showing the first {QUEUE_ROWS_SHOWN} of {}.",
|
||||
items.len()
|
||||
)
|
||||
} else {
|
||||
"Changes made on this computer that are not on Proton Drive yet.".to_string()
|
||||
}));
|
||||
let now = glib::DateTime::now_utc()
|
||||
.map(|now| now.to_unix())
|
||||
.unwrap_or_default();
|
||||
let mut rows = queue.rows.borrow_mut();
|
||||
for op in items.into_iter().take(QUEUE_ROWS_SHOWN) {
|
||||
let row = queue_row(ui, op, now);
|
||||
queue.group.add(&row);
|
||||
rows.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
fn queue_row(ui: &Rc<Ui>, op: &PendingOpInfo, now: i64) -> adw::ActionRow {
|
||||
let (icon, action) = match op.kind.as_str() {
|
||||
"revision" => ("pdfs-upload-symbolic", "Upload changes"),
|
||||
"create" => ("pdfs-upload-symbolic", "Upload new file"),
|
||||
"mkdir" => ("folder-new-symbolic", "Create folder"),
|
||||
"rename" => ("document-edit-symbolic", "Rename or move"),
|
||||
"trash" => ("user-trash-symbolic", "Move to trash"),
|
||||
_ => ("emblem-synchronizing-symbolic", "Change"),
|
||||
};
|
||||
let state = match (op.parked, op.next_attempt_at) {
|
||||
(true, _) => "waiting for the app writing it to finish".to_string(),
|
||||
(false, Some(at)) if at > now && op.attempts > 0 => {
|
||||
format!("retrying {}", clock_time(at))
|
||||
}
|
||||
(false, _) if ui.locations.card.paused.get() => "waiting for sync to resume".to_string(),
|
||||
(false, _) => "up next".to_string(),
|
||||
};
|
||||
let mut subtitle = format!("{action} · {state}");
|
||||
if let Some(error) = &op.last_error {
|
||||
subtitle.push_str(&format!(
|
||||
"\nFailed {}: {error}",
|
||||
count_noun(op.attempts.max(0) as usize, "time", "times")
|
||||
));
|
||||
}
|
||||
let row = adw::ActionRow::builder()
|
||||
.title(glib::markup_escape_text(&op.path).as_str())
|
||||
.subtitle(glib::markup_escape_text(&subtitle).as_str())
|
||||
.title_lines(1)
|
||||
.subtitle_lines(3)
|
||||
.tooltip_text(&op.path)
|
||||
.build();
|
||||
let image = gtk4::Image::from_icon_name(if op.failing {
|
||||
"dialog-warning-symbolic"
|
||||
} else {
|
||||
icon
|
||||
});
|
||||
if op.failing {
|
||||
image.add_css_class("error");
|
||||
}
|
||||
row.add_prefix(&image);
|
||||
let waiting = !op.parked && op.attempts > 0 && op.next_attempt_at.is_some_and(|at| at > now);
|
||||
if waiting {
|
||||
let retry = gtk4::Button::builder()
|
||||
.icon_name("view-refresh-symbolic")
|
||||
.tooltip_text("Retry now")
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
retry.add_css_class("flat");
|
||||
let ui = ui.clone();
|
||||
let id = op.id;
|
||||
retry.connect_clicked(move |_| retry_queued(&ui, Some(id)));
|
||||
row.add_suffix(&retry);
|
||||
}
|
||||
row
|
||||
}
|
||||
|
||||
/// Ask the daemon to stop waiting out one op's backoff, or every failed op's.
|
||||
fn retry_queued(ui: &Rc<Ui>, id: Option<i64>) {
|
||||
let rx = spawn_request(ui.dirs.control_socket(), Request::RetryPendingOp { id });
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
match rx.recv().await {
|
||||
Ok(Ok(Response::Ok { .. })) => {
|
||||
toast(&ui, "Retrying now…");
|
||||
ui.locations.queue.painted.borrow_mut().clear();
|
||||
refresh_queue(&ui);
|
||||
}
|
||||
Ok(Ok(Response::Error { message, kind })) => {
|
||||
toast_failure(&ui, "Couldn't retry", &message, kind)
|
||||
}
|
||||
_ => toast_error(&ui, "Couldn't retry", "The mount service didn't respond."),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Show a status page in place of the locations list.
|
||||
|
||||
@@ -615,7 +615,10 @@ pub(crate) fn refresh(ui: &Rc<Ui>) {
|
||||
// keeps this from asking more than once a minute.
|
||||
refresh_quota(ui);
|
||||
match ui.stack.visible_child_name().as_deref() {
|
||||
Some("locations") => refresh_locations(ui),
|
||||
Some("locations") => {
|
||||
refresh_locations(ui);
|
||||
refresh_queue(ui);
|
||||
}
|
||||
Some("activity") => refresh_activity(ui),
|
||||
_ => {}
|
||||
}
|
||||
@@ -940,6 +943,8 @@ pub(crate) fn refresh_status(ui: &Rc<Ui>) {
|
||||
pending_changes,
|
||||
failing_ops,
|
||||
failing_error,
|
||||
paused,
|
||||
paused_until,
|
||||
..
|
||||
})) => {
|
||||
set_mounted(&ui, true);
|
||||
@@ -947,7 +952,12 @@ pub(crate) fn refresh_status(ui: &Rc<Ui>) {
|
||||
// in it: it is why a file that looks saved is not on the remote
|
||||
// yet, and offline is usually the reason it is still queued.
|
||||
let queued = pending_summary(pending_uploads, pending_changes);
|
||||
let state = if failing_ops > 0 {
|
||||
let state = if paused {
|
||||
SyncState::Paused {
|
||||
until: paused_until,
|
||||
queued,
|
||||
}
|
||||
} else if failing_ops > 0 {
|
||||
SyncState::Attention {
|
||||
count: failing_ops,
|
||||
error: failing_error,
|
||||
@@ -994,6 +1004,13 @@ pub(crate) fn refresh_status(ui: &Rc<Ui>) {
|
||||
|
||||
/// What the sidebar's status strip reports, most urgent first.
|
||||
pub(crate) enum SyncState {
|
||||
/// The user paused syncing; nothing goes up until it resumes. Outranks
|
||||
/// everything else, because it is the reason for everything else.
|
||||
Paused {
|
||||
/// Unix second the pause ends by itself; `None` until resumed.
|
||||
until: Option<i64>,
|
||||
queued: Option<String>,
|
||||
},
|
||||
/// Operations keep failing; the user may have to act.
|
||||
Attention {
|
||||
count: u64,
|
||||
@@ -1014,9 +1031,23 @@ pub(crate) enum SyncState {
|
||||
Disconnected,
|
||||
}
|
||||
|
||||
/// Paint the sidebar status strip: icon, one-line state, and a detail line.
|
||||
/// Paint the sidebar status strip and the Sync page's status card: icon,
|
||||
/// one-line state, and a detail line.
|
||||
fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
|
||||
let paused = matches!(state, SyncState::Paused { .. });
|
||||
let connected = !matches!(state, SyncState::Disconnected);
|
||||
let (icon, class, title, detail) = match state {
|
||||
SyncState::Paused { until, queued } => (
|
||||
"media-playback-pause-symbolic",
|
||||
Some("warning"),
|
||||
"Sync paused".to_string(),
|
||||
Some(match (until, queued) {
|
||||
(Some(until), Some(queued)) => format!("{queued} · resumes {}", clock_time(until)),
|
||||
(Some(until), None) => format!("Resumes {}", clock_time(until)),
|
||||
(None, Some(queued)) => queued,
|
||||
(None, None) => "Until you resume".to_string(),
|
||||
}),
|
||||
),
|
||||
SyncState::Attention { count, error } => (
|
||||
"dialog-warning-symbolic",
|
||||
Some("error"),
|
||||
@@ -1066,6 +1097,35 @@ fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
|
||||
.status_detail
|
||||
.set_label(detail.as_deref().unwrap_or_default());
|
||||
ui.status.status_detail.set_tooltip_text(detail.as_deref());
|
||||
paint_sync_card(
|
||||
ui,
|
||||
icon,
|
||||
class,
|
||||
&title,
|
||||
detail.as_deref(),
|
||||
paused,
|
||||
connected,
|
||||
);
|
||||
}
|
||||
|
||||
/// "at 14:30" today, "Tue 09:00" within the week, a date beyond that.
|
||||
pub(crate) fn clock_time(unix: i64) -> String {
|
||||
let (Ok(at), Ok(now)) = (
|
||||
glib::DateTime::from_unix_local(unix),
|
||||
glib::DateTime::now_local(),
|
||||
) else {
|
||||
return "later".to_string();
|
||||
};
|
||||
let format = if at.ymd() == now.ymd() {
|
||||
"at %H:%M"
|
||||
} else if at.difference(&now).as_seconds() < 6 * 86_400 {
|
||||
"%a %H:%M"
|
||||
} else {
|
||||
"%e %b"
|
||||
};
|
||||
at.format(format)
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|_| "later".to_string())
|
||||
}
|
||||
|
||||
/// Render the pins group from `pins`, with the unpin buttons enabled only while a
|
||||
|
||||
@@ -39,6 +39,8 @@ struct DriveState {
|
||||
/// One-line sync summary of in-flight transfers, empty when idle. Shown as a
|
||||
/// disabled menu line under the status line.
|
||||
sync: String,
|
||||
/// Whether the user has sync paused; decides Pause vs Resume.
|
||||
paused: bool,
|
||||
}
|
||||
|
||||
/// Summarise a work snapshot into one menu line, or empty when idle. A single
|
||||
@@ -82,6 +84,7 @@ fn sync_line(items: &[TransferItem], jobs: &[JobItem]) -> String {
|
||||
|
||||
struct DriveTray {
|
||||
state: DriveState,
|
||||
socket: PathBuf,
|
||||
}
|
||||
|
||||
/// Ask the daemon for its status, falling back to the stored session so the
|
||||
@@ -94,15 +97,23 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
|
||||
online,
|
||||
pending_uploads,
|
||||
pending_changes,
|
||||
failing_ops,
|
||||
paused,
|
||||
..
|
||||
}) => DriveState {
|
||||
line: match (online, pending_summary(pending_uploads, pending_changes)) {
|
||||
_ if paused => "Sync paused".to_string(),
|
||||
_ if failing_ops > 0 => match failing_ops {
|
||||
1 => "1 change needs attention".to_string(),
|
||||
n => format!("{n} changes need attention"),
|
||||
},
|
||||
(true, None) => format!("Mounted at {mountpoint} ({pinned} pinned)"),
|
||||
(true, Some(q)) => format!("Syncing — {q} ({pinned} pinned)"),
|
||||
(false, None) => format!("Offline — cached files only ({pinned} pinned)"),
|
||||
(false, Some(q)) => format!("Offline — {q}"),
|
||||
},
|
||||
mounted: true,
|
||||
paused,
|
||||
mountpoint: PathBuf::from(mountpoint),
|
||||
// Same daemon is up, so a cheap follow-up poll gives the sync line.
|
||||
sync: match send(socket, &Request::GetQueueStatus) {
|
||||
@@ -116,6 +127,7 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
|
||||
mounted: true,
|
||||
mountpoint: default_mountpoint.to_path_buf(),
|
||||
sync: String::new(),
|
||||
paused: false,
|
||||
},
|
||||
// No daemon: describe login state instead so the menu is still useful.
|
||||
Err(_) => {
|
||||
@@ -129,6 +141,7 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
|
||||
mounted: false,
|
||||
mountpoint: default_mountpoint.to_path_buf(),
|
||||
sync: String::new(),
|
||||
paused: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -209,6 +222,27 @@ impl Tray for DriveTray {
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
// Pause holds uploads back but leaves the mount readable, so it
|
||||
// sits apart from Disconnect, which takes the whole mount away.
|
||||
let paused = self.state.paused;
|
||||
items.push(
|
||||
StandardItem {
|
||||
label: if paused { "Resume Sync" } else { "Pause Sync" }.into(),
|
||||
activate: Box::new(move |this: &mut Self| {
|
||||
let request = Request::SetSyncPaused {
|
||||
paused: !paused,
|
||||
until: None,
|
||||
};
|
||||
match send(&this.socket, &request) {
|
||||
Ok(Response::Ok { .. }) => this.state.paused = !paused,
|
||||
Ok(other) => tracing::warn!("pause sync: {other:?}"),
|
||||
Err(e) => tracing::warn!("pause sync: {e}"),
|
||||
}
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
// Stop the systemd service (clean SIGTERM → lazy unmount). The next
|
||||
// login or reboot brings it back; this is a deliberate disconnect.
|
||||
items.push(
|
||||
@@ -281,6 +315,7 @@ fn main() -> Result<()> {
|
||||
|
||||
let tray = DriveTray {
|
||||
state: poll_state(&socket, &default_mountpoint),
|
||||
socket: socket.clone(),
|
||||
};
|
||||
|
||||
// The tray autostarts with the session and can beat the desktop's SNI
|
||||
|
||||
Reference in new issue
Block a user