mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
A new sync folder row starts with state idle, so the app called a folder up to date before any of it reached Drive (B261, issue #53). A mirror folder that has never synced now reads "waiting for first sync", and the This computer row counts such folders. All catalogs translated.
1861 lines
67 KiB
Rust
1861 lines
67 KiB
Rust
//! The Sync page (formerly Locations), in three views: Overview (what sync
|
|
//! is doing, what needs a decision, what is moving), Folders (every local
|
|
//! place Proton Drive occupies on this machine) and History (the activity
|
|
//! feed, built in `activity.rs`).
|
|
//!
|
|
//! One row per [`MountSpec`] from [`Request::ListLocations`] — the primary
|
|
//! `~/ProtonDrive` mount, plus each folder this computer backs up, whether it is
|
|
//! a mirrored local directory or an on-demand FUSE session. The page is called
|
|
//! *Locations* rather than *Mounts* because a mirror folder is a plain directory
|
|
//! with no FUSE session behind it.
|
|
//!
|
|
//! The device rows drive the same control requests the Computers page used to:
|
|
//! mode switch, sync now, remove. What is new here is that the primary mount is
|
|
//! listed alongside them, with the mountpoint chooser that used to live in
|
|
//! Settings.
|
|
|
|
use crate::*;
|
|
|
|
pub(crate) struct LocationsState {
|
|
pub(crate) content: gtk4::Stack,
|
|
pub(crate) status: adw::StatusPage,
|
|
pub(crate) retry: gtk4::Button,
|
|
pub(crate) group: adw::PreferencesGroup,
|
|
pub(crate) rows: RefCell<Vec<LocationRow>>,
|
|
pub(crate) inflight: Cell<bool>,
|
|
/// Shows the loading state if a full load is slow; the rows stay up
|
|
/// until then.
|
|
pub(crate) loader: Rc<Loader>,
|
|
pub(crate) card: SyncCard,
|
|
pub(crate) queue: QueueState,
|
|
pub(crate) conflicts: ConflictsState,
|
|
/// Overview, Folders and History.
|
|
pub(crate) views: adw::ViewStack,
|
|
/// Points at the Overview while conflicts or failures wait there.
|
|
pub(crate) banner: adw::Banner,
|
|
/// Changes the daemon failed to upload, from the last status.
|
|
pub(crate) failing: Cell<u64>,
|
|
}
|
|
|
|
/// The status card heading the Sync page's Overview, and Pause/Resume in the
|
|
/// page header.
|
|
pub(crate) struct SyncCard {
|
|
pub(crate) icon: gtk4::Image,
|
|
pub(crate) title: gtk4::Label,
|
|
pub(crate) detail: gtk4::Label,
|
|
pub(crate) pause: adw::SplitButton,
|
|
/// Whether the last status said paused, so the button knows which way to go.
|
|
pub(crate) paused: Cell<bool>,
|
|
/// Shown only while Proton refuses the session; opens the sign-in page.
|
|
pub(crate) sign_in: gtk4::Button,
|
|
/// Shown only while the daemon is older than the app; restarts it.
|
|
pub(crate) restart: gtk4::Button,
|
|
}
|
|
|
|
/// What a folder row's controls are built from. The subtitle and progress bar
|
|
/// change on every tick of a running pass and are updated in place; anything
|
|
/// in here changes which controls the row has, so a new key rebuilds it.
|
|
pub(crate) type LocationKey = (
|
|
i64,
|
|
MountKind,
|
|
String,
|
|
MountMode,
|
|
Option<MountMode>,
|
|
bool,
|
|
MountAccess,
|
|
);
|
|
|
|
/// One row of the folder list, kept so the refresh tick can update it in place
|
|
/// instead of replacing it under the pointer every two seconds.
|
|
pub(crate) struct LocationRow {
|
|
/// `None` for the "No locations" placeholder.
|
|
key: Option<LocationKey>,
|
|
row: adw::ActionRow,
|
|
progress: gtk4::ProgressBar,
|
|
}
|
|
|
|
/// What a queue row shows that can change: id, attempts, next attempt, parked,
|
|
/// issue.
|
|
pub(crate) type QueueKey = (i64, i64, Option<i64>, bool, Option<SyncIssue>);
|
|
|
|
/// 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>,
|
|
}
|
|
|
|
/// 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>,
|
|
}
|
|
|
|
/// Widgets the Locations page's load/repaint touch.
|
|
pub(crate) struct LocationsWidgets {
|
|
pub(crate) content: gtk4::Stack,
|
|
pub(crate) status: adw::StatusPage,
|
|
pub(crate) group: adw::PreferencesGroup,
|
|
pub(crate) retry: gtk4::Button,
|
|
pub(crate) add_folder: gtk4::Button,
|
|
/// Live transfers, on the Overview; painted by the refresh loop.
|
|
pub(crate) transfers_group: adw::PreferencesGroup,
|
|
pub(crate) card_icon: gtk4::Image,
|
|
pub(crate) card_title: gtk4::Label,
|
|
pub(crate) card_detail: gtk4::Label,
|
|
pub(crate) card_sign_in: gtk4::Button,
|
|
pub(crate) card_restart: gtk4::Button,
|
|
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) views: adw::ViewStack,
|
|
pub(crate) banner: adw::Banner,
|
|
}
|
|
|
|
/// The Sync page: Overview (the status, conflicts, transfers and queue),
|
|
/// Folders (this computer's synced folders) and History (the activity feed,
|
|
/// `history`), switched from the header.
|
|
pub(crate) fn build_locations_page(history: >k4::Widget) -> (gtk4::Widget, LocationsWidgets) {
|
|
let add_folder = gtk4::Button::builder()
|
|
.child(
|
|
&adw::ButtonContent::builder()
|
|
.label(gettext("Add Folder"))
|
|
.icon_name("list-add-symbolic")
|
|
.build(),
|
|
)
|
|
.tooltip_text(gettext(
|
|
"Back a local folder up to this computer's Proton Drive device",
|
|
))
|
|
.valign(gtk4::Align::Center)
|
|
.build();
|
|
add_folder.add_css_class("flat");
|
|
let transfers_group = build_transfers_group();
|
|
let (card, card_icon, card_title, card_detail, card_sign_in, card_restart) = build_sync_card();
|
|
let pause = build_pause_button();
|
|
let (queue_group, retry_all) = build_queue_group();
|
|
let conflicts_group = adw::PreferencesGroup::builder()
|
|
.title(gettext("Conflicts"))
|
|
.description(gettext(
|
|
"Files changed in two places at once. Both versions were kept; choose which one stays.",
|
|
))
|
|
.visible(false)
|
|
.build();
|
|
|
|
// Overview: what sync is doing, then what needs a decision, then what is
|
|
// moving.
|
|
let overview = gtk4::Box::new(gtk4::Orientation::Vertical, 24);
|
|
overview.append(&card);
|
|
overview.append(&conflicts_group);
|
|
overview.append(&transfers_group);
|
|
overview.append(&queue_group);
|
|
|
|
// Folders: the explanation of the two modes waits behind a "?" rather
|
|
// than sitting over the list as a paragraph.
|
|
let help = gtk4::Label::builder()
|
|
.label(gettext("Synced folders keep a full copy on this computer. Online-only folders fetch each file when you open it."))
|
|
.wrap(true)
|
|
.max_width_chars(40)
|
|
.xalign(0.0)
|
|
.margin_top(6)
|
|
.margin_bottom(6)
|
|
.margin_start(6)
|
|
.margin_end(6)
|
|
.build();
|
|
let help_button = gtk4::MenuButton::builder()
|
|
.icon_name("info-outline-symbolic")
|
|
.tooltip_text(gettext("About Sync Modes"))
|
|
.valign(gtk4::Align::Center)
|
|
.popover(>k4::Popover::builder().child(&help).build())
|
|
.build();
|
|
help_button.add_css_class("flat");
|
|
let suffix = gtk4::Box::new(gtk4::Orientation::Horizontal, 6);
|
|
suffix.append(&help_button);
|
|
suffix.append(&add_folder);
|
|
let group = adw::PreferencesGroup::builder()
|
|
.title(gettext("Folders"))
|
|
.header_suffix(&suffix)
|
|
.build();
|
|
|
|
let retry = gtk4::Button::builder()
|
|
.label(gettext("Retry"))
|
|
.halign(gtk4::Align::Center)
|
|
.build();
|
|
retry.add_css_class("pill");
|
|
retry.add_css_class("suggested-action");
|
|
retry.set_visible(false);
|
|
let status = adw::StatusPage::builder()
|
|
.icon_name("drive-harddisk-symbolic")
|
|
.title(gettext("Loading…"))
|
|
.child(&retry)
|
|
.build();
|
|
// A bare `StatusPage` asks for more width than a half-screen window has, and
|
|
// a `Stack` is as wide as its widest child — so an unwrapped status page
|
|
// silently widens the *list* page too, pushing each row's controls off the
|
|
// right edge. Scrolling it (never horizontally) caps that demand.
|
|
let status_scroll = gtk4::ScrolledWindow::builder()
|
|
.vexpand(true)
|
|
.hscrollbar_policy(gtk4::PolicyType::Never)
|
|
.child(&status)
|
|
.build();
|
|
|
|
let content = gtk4::Stack::new();
|
|
content.add_named(&scrolled_column(&group), Some("list"));
|
|
content.add_named(&status_scroll, Some("status"));
|
|
|
|
let views = adw::ViewStack::new();
|
|
views.set_vexpand(true);
|
|
views.add_titled_with_icon(
|
|
&scrolled_column(&overview),
|
|
Some("overview"),
|
|
&pgettext("sync view", "Overview"),
|
|
"pdfs-sync-symbolic",
|
|
);
|
|
views.add_titled_with_icon(
|
|
&content,
|
|
Some("folders"),
|
|
&pgettext("sync view", "Folders"),
|
|
"folder-symbolic",
|
|
);
|
|
views.add_titled_with_icon(
|
|
history,
|
|
Some("history"),
|
|
&pgettext("sync view", "History"),
|
|
"document-open-recent-symbolic",
|
|
);
|
|
|
|
// Conflicts and failures are on the Overview; away from it, a banner
|
|
// says they are there.
|
|
let banner = adw::Banner::builder()
|
|
.button_label(gettext("Review"))
|
|
.build();
|
|
let column = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
|
column.append(&banner);
|
|
column.append(&views);
|
|
|
|
let switcher = adw::ViewSwitcher::builder()
|
|
.stack(&views)
|
|
.policy(adw::ViewSwitcherPolicy::Wide)
|
|
.build();
|
|
let (frame, header) = page_frame_with(&switcher, &column);
|
|
header.pack_start(&pause);
|
|
|
|
let widgets = LocationsWidgets {
|
|
content: content.clone(),
|
|
status: status.clone(),
|
|
group: group.clone(),
|
|
retry: retry.clone(),
|
|
add_folder,
|
|
transfers_group,
|
|
card_icon,
|
|
card_title,
|
|
card_detail,
|
|
card_sign_in,
|
|
card_restart,
|
|
pause,
|
|
queue_group,
|
|
retry_all,
|
|
conflicts_group,
|
|
views,
|
|
banner,
|
|
};
|
|
(frame.upcast(), widgets)
|
|
}
|
|
|
|
/// `child` across the page's full width, scrolling vertically only, with page
|
|
/// margins.
|
|
fn scrolled_column(child: &impl IsA<gtk4::Widget>) -> gtk4::ScrolledWindow {
|
|
let column = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
|
column.append(child);
|
|
column.set_margin_top(18);
|
|
column.set_margin_bottom(18);
|
|
column.set_margin_start(18);
|
|
column.set_margin_end(18);
|
|
// Never scroll sideways: a location's subtitle holds a full path, and letting
|
|
// the row grow to fit one pushes the controls at its end off screen.
|
|
// Constrained, the path ellipsizes and the controls stay reachable.
|
|
gtk4::ScrolledWindow::builder()
|
|
.vexpand(true)
|
|
.hscrollbar_policy(gtk4::PolicyType::Never)
|
|
.child(&column)
|
|
.build()
|
|
}
|
|
|
|
pub(crate) fn wire_locations(ui: &Rc<Ui>, retry: >k4::Button, add_folder: >k4::Button) {
|
|
let ui_retry = ui.clone();
|
|
retry.connect_clicked(move |_| {
|
|
load_locations(&ui_retry);
|
|
});
|
|
let ui_add = ui.clone();
|
|
add_folder.connect_clicked(move |_| prompt_add_sync_folder(&ui_add));
|
|
wire_pause(ui);
|
|
let ui_sign_in = ui.clone();
|
|
ui.locations
|
|
.card
|
|
.sign_in
|
|
.connect_clicked(move |_| show_login(&ui_sign_in));
|
|
let ui_restart = ui.clone();
|
|
ui.locations
|
|
.card
|
|
.restart
|
|
.connect_clicked(move |_| restart_service_then(&ui_restart, |_| {}));
|
|
let ui_retry = ui.clone();
|
|
ui.locations
|
|
.queue
|
|
.retry_all
|
|
.connect_clicked(move |_| retry_queued(&ui_retry, None));
|
|
let ui_review = ui.clone();
|
|
ui.locations.banner.connect_button_clicked(move |_| {
|
|
ui_review.locations.views.set_visible_child_name("overview");
|
|
});
|
|
let ui_views = ui.clone();
|
|
ui.locations
|
|
.views
|
|
.connect_visible_child_name_notify(move |_| {
|
|
paint_sync_banner(&ui_views);
|
|
if ui_views.stack.visible_child_name().as_deref() == Some("locations") {
|
|
load_sync_view(&ui_views);
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Load what the Sync page's current view shows, on arrival.
|
|
pub(crate) fn load_sync_view(ui: &Rc<Ui>) {
|
|
match ui.locations.views.visible_child_name().as_deref() {
|
|
// Activity changes out from under the page as background uploads and
|
|
// edits complete, so it reloads on every visit.
|
|
Some("history") => load_activity(ui),
|
|
_ => {
|
|
refresh_conflicts(ui);
|
|
load_locations(ui);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Reload the Sync page's current view, for F5.
|
|
pub(crate) fn reload_sync_view(ui: &Rc<Ui>) {
|
|
if ui.locations.views.visible_child_name().as_deref() == Some("history") {
|
|
load_activity(ui);
|
|
} else {
|
|
refresh_conflicts(ui);
|
|
load_locations(ui);
|
|
}
|
|
}
|
|
|
|
/// Bring the Sync page's current view up to date with what the daemon says
|
|
/// changed. Conflicts are left to the caller: they badge the sidebar, so they
|
|
/// are followed on every page.
|
|
pub(crate) fn follow_sync_view(ui: &Rc<Ui>, topics: &[Topic]) {
|
|
if activity_visible(ui) {
|
|
if topics.contains(&Topic::Activity) {
|
|
refresh_activity(ui);
|
|
}
|
|
} else {
|
|
if topics.contains(&Topic::Locations) {
|
|
refresh_locations(ui);
|
|
}
|
|
if topics.contains(&Topic::Queue) {
|
|
refresh_queue(ui);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Show the banner over Folders and History while conflicts or failed
|
|
/// changes wait on the Overview, and badge the sidebar's Sync row with them.
|
|
pub(crate) fn paint_sync_banner(ui: &Rc<Ui>) {
|
|
let state = &ui.locations;
|
|
let conflicts = state.conflicts.painted.borrow().len() as u64;
|
|
let failing = state.failing.get();
|
|
set_sidebar_badge(ui, "locations", conflicts + failing);
|
|
let title = match (conflicts, failing) {
|
|
(0, 0) => None,
|
|
(0, n) => Some(ngettext_f(
|
|
"{n} change couldn't be uploaded",
|
|
"{n} changes couldn't be uploaded",
|
|
n,
|
|
&[],
|
|
)),
|
|
(n, 0) => Some(ngettext_f(
|
|
"{n} file was changed in two places at once",
|
|
"{n} files were changed in two places at once",
|
|
n,
|
|
&[],
|
|
)),
|
|
_ => Some(gettext("Some files need your attention")),
|
|
};
|
|
let away = state.views.visible_child_name().as_deref() != Some("overview");
|
|
state.banner.set_title(title.as_deref().unwrap_or_default());
|
|
state.banner.set_revealed(away && title.is_some());
|
|
}
|
|
|
|
/// The status hero: a large state icon, the state in words, what it means,
|
|
/// and the way out when there is one only the user can take.
|
|
fn build_sync_card() -> (
|
|
gtk4::Box,
|
|
gtk4::Image,
|
|
gtk4::Label,
|
|
gtk4::Label,
|
|
gtk4::Button,
|
|
gtk4::Button,
|
|
) {
|
|
let icon = gtk4::Image::builder()
|
|
.icon_name("pdfs-sync-symbolic")
|
|
.pixel_size(48)
|
|
.valign(gtk4::Align::Center)
|
|
.build();
|
|
icon.add_css_class("sync-card-icon");
|
|
let title = gtk4::Label::builder()
|
|
.label(gettext("Checking…"))
|
|
.xalign(0.0)
|
|
.wrap(true)
|
|
.build();
|
|
title.add_css_class("title-2");
|
|
let detail = gtk4::Label::builder()
|
|
.xalign(0.0)
|
|
.wrap(true)
|
|
.visible(false)
|
|
.build();
|
|
detail.add_css_class("dim-label");
|
|
let text = gtk4::Box::new(gtk4::Orientation::Vertical, 4);
|
|
text.set_valign(gtk4::Align::Center);
|
|
text.set_hexpand(true);
|
|
text.append(&title);
|
|
text.append(&detail);
|
|
let card = gtk4::Box::new(gtk4::Orientation::Horizontal, 18);
|
|
card.add_css_class("card");
|
|
card.add_css_class("sync-card");
|
|
let sign_in = gtk4::Button::builder()
|
|
.label(gettext("Sign In Again"))
|
|
.valign(gtk4::Align::Center)
|
|
.visible(false)
|
|
.build();
|
|
sign_in.add_css_class("pill");
|
|
sign_in.add_css_class("suggested-action");
|
|
let restart = gtk4::Button::builder()
|
|
.label(gettext("Restart"))
|
|
.valign(gtk4::Align::Center)
|
|
.visible(false)
|
|
.build();
|
|
restart.add_css_class("pill");
|
|
restart.add_css_class("suggested-action");
|
|
card.append(&icon);
|
|
card.append(&text);
|
|
card.append(&sign_in);
|
|
card.append(&restart);
|
|
(card, icon, title, detail, sign_in, restart)
|
|
}
|
|
|
|
/// Pause/Resume, with timed pauses in its menu.
|
|
fn build_pause_button() -> adw::SplitButton {
|
|
let menu = gio::Menu::new();
|
|
menu.append(
|
|
Some(&gettext("Pause for 1 Hour")),
|
|
Some("sync.pause-for(int64 3600)"),
|
|
);
|
|
menu.append(
|
|
Some(&gettext("Pause for 8 Hours")),
|
|
Some("sync.pause-for(int64 28800)"),
|
|
);
|
|
menu.append(
|
|
Some(&gettext("Pause for 24 Hours")),
|
|
Some("sync.pause-for(int64 86400)"),
|
|
);
|
|
adw::SplitButton::builder()
|
|
.label(pgettext("verb", "Pause"))
|
|
.menu_model(&menu)
|
|
.valign(gtk4::Align::Center)
|
|
.tooltip_text(gettext(
|
|
"Stop uploading until you resume. Files still open as usual.",
|
|
))
|
|
.dropdown_tooltip(gettext("Pause for a while"))
|
|
.build()
|
|
}
|
|
|
|
/// 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.title.set_label(title);
|
|
card.detail
|
|
.set_visible(detail.is_some_and(|d| !d.is_empty()));
|
|
card.detail.set_label(detail.unwrap_or_default());
|
|
card.paused.set(paused);
|
|
card.pause.set_label(&if paused {
|
|
pgettext("verb", "Resume")
|
|
} else {
|
|
pgettext("verb", "Pause")
|
|
});
|
|
card.pause.set_tooltip_text(Some(&if paused {
|
|
gettext("Upload everything that waited while sync was paused")
|
|
} else {
|
|
gettext("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 {
|
|
gettext("Couldn't pause sync")
|
|
} else {
|
|
gettext("Couldn't resume sync")
|
|
};
|
|
match rx.recv().await {
|
|
Ok(Ok(Response::Ok { .. })) => toast(
|
|
&ui,
|
|
&match (paused, until) {
|
|
(false, _) => gettext("Sync resumed"),
|
|
(true, None) => gettext("Sync paused until you resume"),
|
|
(true, Some(_)) => gettext("Sync paused"),
|
|
},
|
|
),
|
|
Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, &what, &message, kind),
|
|
_ => toast_error(
|
|
&ui,
|
|
&what,
|
|
&gettext("The Proton Drive 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(gettext("Retry All"))
|
|
.valign(gtk4::Align::Center)
|
|
.tooltip_text(gettext("Try every failed change again now"))
|
|
.visible(false)
|
|
.build();
|
|
retry_all.add_css_class("flat");
|
|
let group = adw::PreferencesGroup::builder()
|
|
.title(gettext("Waiting to Upload"))
|
|
.description(gettext(
|
|
"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, op.issue))
|
|
.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 {
|
|
let shown = QUEUE_ROWS_SHOWN.to_string();
|
|
// Translators: {shown} is how many rows are listed (50), {n} the total number of queued changes.
|
|
ngettext_f("Changes made on this computer that are not on Proton Drive yet. Showing the first {shown} of {n}.", "Changes made on this computer that are not on Proton Drive yet. Showing the first {shown} of {n}.", items.len() as u64, &[("shown", &shown)])
|
|
} else {
|
|
gettext("Changes made on this computer that are not on Proton Drive yet.")
|
|
}));
|
|
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", gettext("Upload changes")),
|
|
"create" => ("pdfs-upload-symbolic", gettext("Upload new file")),
|
|
"mkdir" => ("folder-new-symbolic", gettext("Create folder")),
|
|
"rename" => ("document-edit-symbolic", gettext("Rename or move")),
|
|
"trash" => ("user-trash-symbolic", gettext("Move to trash")),
|
|
// Translators: a queued change of an unknown kind.
|
|
_ => ("pdfs-sync-symbolic", pgettext("noun", "Change")),
|
|
};
|
|
let state = match (op.parked, op.next_attempt_at) {
|
|
(true, _) => gettext("waiting for the app writing it to finish"),
|
|
(false, Some(at)) if at > now && op.attempts > 0 => {
|
|
// Translators: {time} is when the next attempt happens, such as "14:30".
|
|
gettext_f("retrying {time}", &[("time", &clock_time(at))])
|
|
}
|
|
(false, _) if ui.locations.card.paused.get() => gettext("waiting for sync to resume"),
|
|
(false, _) => gettext("up next"),
|
|
};
|
|
// Translators: a queued change's kind and its state, such as "Upload changes · up next".
|
|
let mut subtitle = gettext_f(
|
|
"{action} · {state}",
|
|
&[("action", &action), ("state", &state)],
|
|
);
|
|
if let Some(issue) = op.issue {
|
|
subtitle.push('\n');
|
|
subtitle.push_str(&issue_text(issue));
|
|
}
|
|
if let Some(error) = &op.last_error {
|
|
subtitle.push('\n');
|
|
// Translators: {n} is how many attempts failed, {error} the last error message.
|
|
subtitle.push_str(&ngettext_f(
|
|
"Failed {n} time: {error}",
|
|
"Failed {n} times: {error}",
|
|
op.attempts.max(0) as u64,
|
|
&[("error", error)],
|
|
));
|
|
}
|
|
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(if op.issue.is_some() { 5 } else { 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()
|
|
.label(gettext("Retry"))
|
|
.tooltip_text(gettext("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);
|
|
}
|
|
if op.failing && op.exportable {
|
|
let export = gtk4::Button::builder()
|
|
.label(gettext("Export"))
|
|
.tooltip_text(gettext("Save a copy of this file's content elsewhere"))
|
|
.valign(gtk4::Align::Center)
|
|
.build();
|
|
export.add_css_class("flat");
|
|
let ui = ui.clone();
|
|
let id = op.id;
|
|
let name = Path::new(&op.path)
|
|
.file_name()
|
|
.map(|name| name.to_string_lossy().into_owned())
|
|
.unwrap_or_default();
|
|
export.connect_clicked(move |_| prompt_export_queued(&ui, id, &name));
|
|
row.add_suffix(&export);
|
|
}
|
|
if op.failing {
|
|
let discard = gtk4::Button::builder()
|
|
.label(gettext("Discard"))
|
|
.tooltip_text(gettext("Undo this change here instead of sending it"))
|
|
.valign(gtk4::Align::Center)
|
|
.build();
|
|
discard.add_css_class("flat");
|
|
let ui = ui.clone();
|
|
let id = op.id;
|
|
let path = op.path.clone();
|
|
let exportable = op.exportable;
|
|
discard.connect_clicked(move |_| prompt_discard_queued(&ui, id, &path, exportable));
|
|
row.add_suffix(&discard);
|
|
}
|
|
row
|
|
}
|
|
|
|
/// What a sync issue means and what the user can do about it. The change stays
|
|
/// queued either way.
|
|
pub(crate) fn issue_text(issue: SyncIssue) -> String {
|
|
match issue {
|
|
SyncIssue::Quota => gettext(
|
|
"Your Proton Drive storage is full. Free up space or upgrade your plan, and this change uploads on its own.",
|
|
),
|
|
SyncIssue::Access => gettext(
|
|
"You can no longer change this on Proton Drive. Ask the owner for edit access, or export the file to keep your version.",
|
|
),
|
|
SyncIssue::Missing => gettext(
|
|
"The folder this change belongs in is gone from Proton Drive. Export the file to keep your version.",
|
|
),
|
|
SyncIssue::Limit => gettext(
|
|
"Proton Drive's limit on items in a folder or on folder depth is reached. Make room in the folder, then retry.",
|
|
),
|
|
SyncIssue::Rejected | SyncIssue::Unknown => {
|
|
gettext("Proton Drive refused this change. It stays queued and is tried again.")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Ask where to save a copy of a queued file's content, then have the daemon
|
|
/// write it there. The change itself stays queued.
|
|
fn prompt_export_queued(ui: &Rc<Ui>, id: i64, name: &str) {
|
|
let window = ui_window(ui);
|
|
let dialog = gtk4::FileDialog::builder()
|
|
.title(gettext("Export File"))
|
|
.initial_name(name)
|
|
.build();
|
|
let ui = ui.clone();
|
|
dialog.save(window.as_ref(), gio::Cancellable::NONE, move |res| {
|
|
let Ok(file) = res else { return };
|
|
let Some(dest) = file.path().and_then(|p| p.to_str().map(str::to_string)) else {
|
|
toast_error(
|
|
&ui,
|
|
&gettext("Couldn't export the file"),
|
|
&gettext("That location isn't a local file."),
|
|
);
|
|
return;
|
|
};
|
|
let rx = spawn_request(
|
|
ui.dirs.control_socket(),
|
|
Request::ExportPendingOp { id, dest },
|
|
);
|
|
let ui = ui.clone();
|
|
glib::spawn_future_local(async move {
|
|
match rx.recv().await {
|
|
Ok(Ok(Response::Ok { .. })) => toast(&ui, &gettext("File exported")),
|
|
Ok(Ok(Response::Error { message, kind })) => {
|
|
toast_failure(&ui, &gettext("Couldn't export the file"), &message, kind)
|
|
}
|
|
_ => toast_error(
|
|
&ui,
|
|
&gettext("Couldn't export the file"),
|
|
&gettext("The Proton Drive service didn't respond."),
|
|
),
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/// Confirm, then have the daemon drop a queued change and undo it here.
|
|
fn prompt_discard_queued(ui: &Rc<Ui>, id: i64, path: &str, exportable: bool) {
|
|
let body = if exportable {
|
|
// Translators: {path} is the file whose queued change would be dropped.
|
|
gettext_f(
|
|
"Your changes to {path} are undone here and never reach Proton Drive. Export the file first to keep your version.",
|
|
&[("path", path)],
|
|
)
|
|
} else {
|
|
// Translators: {path} is the file or folder whose queued change would be dropped.
|
|
gettext_f(
|
|
"The change to {path} is undone here and never reaches Proton Drive.",
|
|
&[("path", path)],
|
|
)
|
|
};
|
|
let dialog = adw::AlertDialog::builder()
|
|
.heading(gettext("Discard Change?"))
|
|
.body(body)
|
|
.build();
|
|
dialog.add_response("cancel", &gettext("Cancel"));
|
|
dialog.add_response("discard", &gettext("Discard"));
|
|
dialog.set_response_appearance("discard", adw::ResponseAppearance::Destructive);
|
|
dialog.set_default_response(Some("cancel"));
|
|
dialog.set_close_response("cancel");
|
|
let window = ui_window(ui);
|
|
let ui = ui.clone();
|
|
dialog.connect_response(None, move |_, resp| {
|
|
if resp == "discard" {
|
|
discard_queued(&ui, id);
|
|
}
|
|
});
|
|
dialog.present(window.as_ref());
|
|
}
|
|
|
|
fn discard_queued(ui: &Rc<Ui>, id: i64) {
|
|
let rx = spawn_request(ui.dirs.control_socket(), Request::DiscardPendingOp { id });
|
|
let ui = ui.clone();
|
|
glib::spawn_future_local(async move {
|
|
match rx.recv().await {
|
|
Ok(Ok(Response::Ok { .. })) => {
|
|
toast(&ui, &gettext("Change discarded"));
|
|
ui.locations.queue.painted.borrow_mut().clear();
|
|
refresh_queue(&ui);
|
|
}
|
|
Ok(Ok(Response::Error { message, kind })) => {
|
|
toast_failure(&ui, &gettext("Couldn't discard the change"), &message, kind)
|
|
}
|
|
_ => toast_error(
|
|
&ui,
|
|
&gettext("Couldn't discard the change"),
|
|
&gettext("The Proton Drive service didn't respond."),
|
|
),
|
|
}
|
|
});
|
|
}
|
|
|
|
/// 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, &gettext("Retrying now…"));
|
|
ui.locations.queue.painted.borrow_mut().clear();
|
|
refresh_queue(&ui);
|
|
}
|
|
Ok(Ok(Response::Error { message, kind })) => {
|
|
toast_failure(&ui, &gettext("Couldn't retry"), &message, kind)
|
|
}
|
|
_ => toast_error(
|
|
&ui,
|
|
&gettext("Couldn't retry"),
|
|
&gettext("The Proton Drive service didn't respond."),
|
|
),
|
|
}
|
|
});
|
|
}
|
|
|
|
/// How long the activity feed's conflict listing stays fresh while the feed
|
|
/// follows new activity. Conflicts come with their own event, so this only
|
|
/// saves reading them twice.
|
|
pub(crate) const CONFLICTS_TTL: Duration = Duration::from_secs(30);
|
|
|
|
/// Read the conflict list again, for the Sync page and the sidebar badge: on
|
|
/// navigation, after a resolution, and when the daemon says it changed.
|
|
pub(crate) fn refresh_conflicts(ui: &Rc<Ui>) {
|
|
let conflicts = &ui.locations.conflicts;
|
|
if conflicts.inflight.get() {
|
|
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);
|
|
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;
|
|
paint_sync_banner(ui);
|
|
|
|
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 {
|
|
// Translators: one side of a conflict; {size} is a file size such as "12.3 MB", {time} when it was changed, such as "5 min ago" or "Sep 21".
|
|
gettext_f(
|
|
"{size}, changed {time}",
|
|
&[
|
|
("size", &human_bytes(size)),
|
|
("time", &dates::relative(modified)),
|
|
],
|
|
)
|
|
}
|
|
|
|
pub(crate) fn conflict_row(ui: &Rc<Ui>, conflict: &ConflictInfo) -> adw::ActionRow {
|
|
let verdict = if !conflict.original_exists {
|
|
gettext("The original is gone; only this copy is left")
|
|
} else if conflict.identical {
|
|
gettext("Same content as the original")
|
|
} else {
|
|
// Translators: {name} is the original file's name.
|
|
gettext_f(
|
|
"Differs from {name}",
|
|
&[("name", 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.
|
|
pub(crate) fn file_name(path: &str) -> &str {
|
|
path.rsplit('/').next().unwrap_or(path)
|
|
}
|
|
|
|
/// Ask which version of a conflicted file to keep.
|
|
pub(crate) 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 => {
|
|
let original = conflict_side(size, modified);
|
|
let copy = conflict_side(conflict.size, conflict.modified);
|
|
// Translators: {name} is the file name; {original} and {copy} each read like "12.3 MB, changed 14:30".
|
|
gettext_f(
|
|
"“{name}” was changed here and elsewhere at the same time.\n\nOriginal: {original}\nCopy: {copy}\n\nWhatever is not kept goes to Trash, where it can be restored.",
|
|
&[
|
|
("name", &original_name),
|
|
("original", &original),
|
|
("copy", ©),
|
|
],
|
|
)
|
|
}
|
|
_ => {
|
|
let copy = conflict_side(conflict.size, conflict.modified);
|
|
// Translators: {name} is the file name; {copy} reads like "12.3 MB, changed 14:30".
|
|
gettext_f(
|
|
"The original “{name}” no longer exists; only the copy is left ({copy}).\n\nKeep the copy to give it the original name back.",
|
|
&[("name", &original_name), ("copy", ©)],
|
|
)
|
|
}
|
|
};
|
|
let dialog = adw::AlertDialog::builder()
|
|
.heading(gettext("Resolve Conflict"))
|
|
.body(body)
|
|
.build();
|
|
let group = adw::PreferencesGroup::new();
|
|
let name_row = adw::EntryRow::builder()
|
|
.title(gettext("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", &gettext("Cancel"));
|
|
if conflict.original_exists {
|
|
dialog.add_response("original", &gettext("Keep Original"));
|
|
dialog.add_response("both", &gettext("Keep Both"));
|
|
}
|
|
dialog.add_response("copy", &gettext("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);
|
|
refresh_activity_conflicts(&ui, true);
|
|
}
|
|
Ok(Ok(Response::Error { message, kind })) => toast_failure(
|
|
&ui,
|
|
&gettext("Couldn't resolve the conflict"),
|
|
&message,
|
|
kind,
|
|
),
|
|
_ => toast_error(
|
|
&ui,
|
|
&gettext("Couldn't resolve the conflict"),
|
|
&gettext("The Proton Drive service didn't respond."),
|
|
),
|
|
}
|
|
});
|
|
}
|
|
|
|
/// First letter upper-cased, for a daemon message used as a sentence.
|
|
pub(crate) 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>,
|
|
icon: &str,
|
|
title: &str,
|
|
description: &str,
|
|
retry: bool,
|
|
) {
|
|
ui.locations.status.set_icon_name(Some(icon));
|
|
ui.locations.status.set_title(title);
|
|
ui.locations
|
|
.status
|
|
.set_description(Some(>k4::glib::markup_escape_text(description)));
|
|
ui.locations.retry.set_visible(retry);
|
|
ui.locations.content.set_visible_child_name("status");
|
|
}
|
|
|
|
/// Fetch every local location and repaint the list.
|
|
pub(crate) fn load_locations(ui: &Rc<Ui>) {
|
|
if ui.locations.inflight.get() {
|
|
return;
|
|
}
|
|
ui.locations.inflight.set(true);
|
|
let ui_p = ui.clone();
|
|
let ticket = ui.locations.loader.refresh(move || {
|
|
locations_status(
|
|
&ui_p,
|
|
"drive-harddisk-symbolic",
|
|
&gettext("Loading…"),
|
|
&gettext("Reading this computer's Proton Drive locations."),
|
|
false,
|
|
);
|
|
});
|
|
ui.busy_begin();
|
|
let rx = spawn_request(ui.dirs.control_socket(), Request::ListLocations);
|
|
let ui = ui.clone();
|
|
glib::spawn_future_local(async move {
|
|
let result = rx.recv().await;
|
|
ui.busy_end();
|
|
ui.locations.inflight.set(false);
|
|
drop(ticket);
|
|
match result {
|
|
Ok(Ok(Response::Locations { items })) => {
|
|
ui.locations.content.set_visible_child_name("list");
|
|
repaint_locations(&ui, &items);
|
|
}
|
|
Ok(Ok(Response::Error { message, .. })) => {
|
|
locations_status(
|
|
&ui,
|
|
"dialog-warning-symbolic",
|
|
&gettext("Unavailable"),
|
|
&message,
|
|
true,
|
|
);
|
|
}
|
|
_ => {
|
|
locations_unreachable(&ui);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/// Refresh the rows with no status flash and no spinner, from the periodic tick:
|
|
/// a sync pass's progress is only live if something re-reads it.
|
|
pub(crate) fn refresh_locations(ui: &Rc<Ui>) {
|
|
if ui.locations.inflight.get() {
|
|
return;
|
|
}
|
|
ui.locations.inflight.set(true);
|
|
let rx = spawn_request(ui.dirs.control_socket(), Request::ListLocations);
|
|
let ui = ui.clone();
|
|
glib::spawn_future_local(async move {
|
|
let result = rx.recv().await;
|
|
ui.locations.inflight.set(false);
|
|
let Ok(Ok(Response::Locations { items })) = result else {
|
|
return;
|
|
};
|
|
// The page may have been navigated away from, or collapsed to a status
|
|
// view, while the request was in flight.
|
|
if ui.stack.visible_child_name().as_deref() == Some("locations")
|
|
&& ui.locations.content.visible_child_name().as_deref() == Some("list")
|
|
{
|
|
repaint_locations(&ui, &items);
|
|
}
|
|
});
|
|
}
|
|
|
|
/// The daemon didn't answer the Locations page.
|
|
pub(crate) fn locations_unreachable(ui: &Rc<Ui>) {
|
|
service_unreachable(ui, "locations", locations_status, load_locations);
|
|
}
|
|
|
|
/// Paint the folder list. The rows are rebuilt only when what they offer
|
|
/// changes (a mode switch, a pause, a folder added); otherwise the tick updates
|
|
/// their subtitle and progress in place, so a menu, tooltip or keyboard focus
|
|
/// on a row survives the refresh.
|
|
pub(crate) fn repaint_locations(ui: &Rc<Ui>, items: &[MountSpec]) {
|
|
let keys: Vec<Option<LocationKey>> = if items.is_empty() {
|
|
vec![None]
|
|
} else {
|
|
items.iter().map(|spec| Some(location_key(spec))).collect()
|
|
};
|
|
let unchanged = {
|
|
let rows = ui.locations.rows.borrow();
|
|
rows.len() == keys.len() && rows.iter().zip(&keys).all(|(row, key)| row.key == *key)
|
|
};
|
|
if !unchanged {
|
|
rebuild_locations(ui, items, keys);
|
|
}
|
|
for (row, spec) in ui.locations.rows.borrow().iter().zip(items) {
|
|
update_location_row(row, spec);
|
|
}
|
|
}
|
|
|
|
fn location_key(spec: &MountSpec) -> LocationKey {
|
|
(
|
|
spec.id,
|
|
spec.kind.clone(),
|
|
spec.local_path.clone(),
|
|
spec.mode,
|
|
spec.pending_mode,
|
|
spec.paused,
|
|
spec.access,
|
|
)
|
|
}
|
|
|
|
fn rebuild_locations(ui: &Rc<Ui>, items: &[MountSpec], keys: Vec<Option<LocationKey>>) {
|
|
for row in ui.locations.rows.borrow_mut().drain(..) {
|
|
ui.locations.group.remove(&row.row);
|
|
}
|
|
let mut rows = Vec::new();
|
|
if items.is_empty() {
|
|
let row = adw::ActionRow::builder()
|
|
.title(gettext("No folders yet"))
|
|
.subtitle(gettext(
|
|
"The Proton Drive service hasn't reported its folder yet.",
|
|
))
|
|
.build();
|
|
row.add_prefix(>k4::Image::from_icon_name("folder-symbolic"));
|
|
ui.locations.group.add(&row);
|
|
rows.push(LocationRow {
|
|
key: None,
|
|
row,
|
|
progress: gtk4::ProgressBar::new(),
|
|
});
|
|
}
|
|
for (spec, key) in items.iter().zip(keys) {
|
|
let row = location_row(ui, spec);
|
|
ui.locations.group.add(&row.row);
|
|
rows.push(LocationRow { key, ..row });
|
|
}
|
|
*ui.locations.rows.borrow_mut() = rows;
|
|
}
|
|
|
|
/// The parts of a folder row that move while a pass runs.
|
|
fn update_location_row(row: &LocationRow, spec: &MountSpec) {
|
|
// Translators: a synced folder's location and state, such as "~/Documents · Synced · up to date".
|
|
let subtitle = gettext_f(
|
|
"{path} · {state}",
|
|
&[
|
|
("path", &tilde_path(&spec.local_path)),
|
|
("state", &location_subtitle(spec)),
|
|
],
|
|
);
|
|
let subtitle = glib::markup_escape_text(&subtitle);
|
|
if row.row.subtitle().as_deref() != Some(subtitle.as_str()) {
|
|
row.row.set_subtitle(&subtitle);
|
|
}
|
|
// A first pass has no estimate to draw against, so the bar appears only
|
|
// once real counts exist.
|
|
match &spec.progress {
|
|
Some(p) if p.total > 0 => {
|
|
row.progress
|
|
.set_fraction((p.done as f64 / p.total.max(p.done) as f64).min(1.0));
|
|
row.progress.set_visible(true);
|
|
}
|
|
_ => row.progress.set_visible(false),
|
|
}
|
|
}
|
|
|
|
/// A folder row: its name, where it is and what it is doing, the folder's mode
|
|
/// as a button that names it, and one menu for everything else. Activating the
|
|
/// row opens the folder.
|
|
fn location_row(ui: &Rc<Ui>, spec: &MountSpec) -> LocationRow {
|
|
let row = adw::ActionRow::builder()
|
|
.title(glib::markup_escape_text(&location_name(spec)).as_str())
|
|
.tooltip_text(&spec.local_path)
|
|
// One line each: a wrapped path would reflow the whole list every time
|
|
// a sync state changed under it.
|
|
.title_lines(1)
|
|
.subtitle_lines(1)
|
|
.activatable(true)
|
|
.build();
|
|
row.add_prefix(>k4::Image::from_icon_name(location_icon(&spec.kind)));
|
|
let path = spec.local_path.clone();
|
|
row.connect_activated(move |_| open_path(&path));
|
|
|
|
// A read-only location cannot be written through even where the files are
|
|
// visible; saying so on the row is cheaper than letting the user find out
|
|
// from a save dialog.
|
|
if spec.access == MountAccess::Ro {
|
|
let badge = gtk4::Label::new(Some(&gettext("Read-only")));
|
|
badge.add_css_class("dim-label");
|
|
badge.add_css_class("caption");
|
|
badge.set_valign(gtk4::Align::Center);
|
|
row.add_suffix(&badge);
|
|
}
|
|
|
|
let progress = gtk4::ProgressBar::builder()
|
|
.valign(gtk4::Align::Center)
|
|
.width_request(120)
|
|
.visible(false)
|
|
.build();
|
|
row.add_suffix(&progress);
|
|
|
|
let mut menu = ActionMenu::new();
|
|
let path = spec.local_path.clone();
|
|
menu.item(&gettext("Open in Files"), move || open_path(&path));
|
|
menu.section();
|
|
match &spec.kind {
|
|
MountKind::MyFiles => {
|
|
let ui = ui.clone();
|
|
menu.item(&gettext("Change Location…"), move || {
|
|
prompt_mountpoint(&ui)
|
|
});
|
|
}
|
|
MountKind::Device { sync_folder_id } => {
|
|
row.add_suffix(&mode_button(ui, spec, *sync_folder_id));
|
|
add_device_items(ui, &mut menu, spec, *sync_folder_id);
|
|
}
|
|
// A standalone shared mount has no local mode to switch and is not
|
|
// this device's to remove.
|
|
MountKind::Shared { .. } => {}
|
|
}
|
|
row.add_suffix(&menu.button());
|
|
|
|
let row = LocationRow {
|
|
key: None,
|
|
row,
|
|
progress,
|
|
};
|
|
update_location_row(&row, spec);
|
|
row
|
|
}
|
|
|
|
/// What a folder row is called: the folder's own name, which is the part of
|
|
/// its path a person recognises.
|
|
fn location_name(spec: &MountSpec) -> String {
|
|
Path::new(&spec.local_path)
|
|
.file_name()
|
|
.and_then(|name| name.to_str())
|
|
.unwrap_or(&spec.local_path)
|
|
.to_string()
|
|
}
|
|
|
|
/// `path` with the home directory written as `~`.
|
|
pub(crate) fn tilde_path(path: &str) -> String {
|
|
let home = glib::home_dir();
|
|
match Path::new(path).strip_prefix(&home) {
|
|
Ok(rest) if rest.as_os_str().is_empty() => "~".to_string(),
|
|
Ok(rest) => format!("~/{}", rest.display()),
|
|
Err(_) => path.to_string(),
|
|
}
|
|
}
|
|
|
|
/// The button naming a synced folder's mode, whose popover switches it. It
|
|
/// shows the mode the folder is heading for: a queued switch was accepted and
|
|
/// will happen, so showing the old mode would read as "it didn't take".
|
|
fn mode_button(ui: &Rc<Ui>, spec: &MountSpec, id: i64) -> gtk4::MenuButton {
|
|
let target = spec.pending_mode.unwrap_or(spec.mode);
|
|
let ondemand = target == MountMode::OnDemand;
|
|
let button = gtk4::MenuButton::builder()
|
|
.label(folder_mode_label(ondemand))
|
|
.tooltip_text(gettext(
|
|
"Choose whether this folder keeps a full copy on this computer",
|
|
))
|
|
.valign(gtk4::Align::Center)
|
|
.always_show_arrow(true)
|
|
// The daemon refuses a mode switch on a paused folder.
|
|
.sensitive(!spec.paused)
|
|
.build();
|
|
button.add_css_class("flat");
|
|
|
|
let options = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
|
|
let popover = gtk4::Popover::builder().child(&options).build();
|
|
popover.add_css_class("menu");
|
|
for (choice, title, description) in [
|
|
(
|
|
false,
|
|
folder_mode_label(false),
|
|
gettext("Keep every file on this computer and in Proton Drive."),
|
|
),
|
|
(
|
|
true,
|
|
folder_mode_label(true),
|
|
gettext("Keep the files in Proton Drive and download each one when you open it."),
|
|
),
|
|
] {
|
|
let text = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
|
|
text.append(
|
|
>k4::Label::builder()
|
|
.label(&title)
|
|
.xalign(0.0)
|
|
.css_classes(["heading"])
|
|
.build(),
|
|
);
|
|
text.append(
|
|
>k4::Label::builder()
|
|
.label(&description)
|
|
.xalign(0.0)
|
|
.wrap(true)
|
|
.max_width_chars(36)
|
|
.css_classes(["caption", "dim-label"])
|
|
.build(),
|
|
);
|
|
let check = gtk4::Image::from_icon_name("object-select-symbolic");
|
|
check.set_opacity(if choice == ondemand { 1.0 } else { 0.0 });
|
|
let content = gtk4::Box::new(gtk4::Orientation::Horizontal, 12);
|
|
content.append(&text);
|
|
content.append(&check);
|
|
text.set_hexpand(true);
|
|
let option = gtk4::Button::builder().child(&content).build();
|
|
option.add_css_class("flat");
|
|
let ui = ui.clone();
|
|
let path = spec.local_path.clone();
|
|
let popover_ref = popover.downgrade();
|
|
option.connect_clicked(move |_| {
|
|
if let Some(popover) = popover_ref.upgrade() {
|
|
popover.popdown();
|
|
}
|
|
if choice == ondemand {
|
|
return;
|
|
}
|
|
if choice {
|
|
confirm_online_only(&ui, id, &path);
|
|
} else {
|
|
confirm_synced(&ui, id, &path);
|
|
}
|
|
});
|
|
options.append(&option);
|
|
}
|
|
button.set_popover(Some(&popover));
|
|
button
|
|
}
|
|
|
|
/// A synced folder's mode, as the row's mode button names it.
|
|
fn folder_mode_label(ondemand: bool) -> String {
|
|
if ondemand {
|
|
// Translators: a folder mode: files are kept in Proton Drive and fetched when opened.
|
|
pgettext("folder mode", "Online only")
|
|
} else {
|
|
// Translators: a folder mode: a full copy is kept on this computer.
|
|
pgettext("folder mode", "Synced")
|
|
}
|
|
}
|
|
|
|
/// Sync now, pause or resume (synced folders only), and Stop syncing, in a
|
|
/// synced folder's menu.
|
|
fn add_device_items(ui: &Rc<Ui>, menu: &mut ActionMenu, spec: &MountSpec, id: i64) {
|
|
if spec.mode != MountMode::OnDemand {
|
|
// A paused folder skips its passes, so Sync now would do nothing.
|
|
if !spec.paused {
|
|
let ui = ui.clone();
|
|
menu.item(&gettext("Sync Now"), move || sync_folder_now(&ui, id));
|
|
}
|
|
let paused = spec.paused;
|
|
let label = if paused {
|
|
gettext("Resume Syncing")
|
|
} else {
|
|
gettext("Pause Syncing")
|
|
};
|
|
let ui_pause = ui.clone();
|
|
menu.item(&label, move || set_folder_paused(&ui_pause, id, !paused));
|
|
let ui = ui.clone();
|
|
let path = spec.local_path.clone();
|
|
menu.item(&gettext("Sync From Scratch…"), move || {
|
|
confirm_reset_sync_folder(&ui, id, &path)
|
|
});
|
|
menu.section();
|
|
}
|
|
let ui = ui.clone();
|
|
let path = spec.local_path.clone();
|
|
// The folder's *current* mode, not a queued one: a switch that hasn't landed
|
|
// yet has not moved the files anywhere.
|
|
let is_ondemand = spec.mode == MountMode::OnDemand;
|
|
menu.item(&gettext("Stop Syncing…"), move || {
|
|
prompt_remove_sync_folder(&ui, id, &path, is_ondemand)
|
|
});
|
|
}
|
|
|
|
/// Ask before syncing a folder from scratch. Nothing is deleted, but files
|
|
/// that differ come back as conflict copies, so it is not a click to make by
|
|
/// accident.
|
|
fn confirm_reset_sync_folder(ui: &Rc<Ui>, id: i64, path: &str) {
|
|
let dialog = adw::AlertDialog::builder()
|
|
.heading(gettext("Sync From Scratch?"))
|
|
// Translators: {path} is a local folder path.
|
|
.body(gettext_f("This computer forgets what it synced in {path} before and compares the folder with Proton Drive again. Nothing is deleted: a file on one side only is copied to the other, and a file that differs keeps both versions.", &[("path", path)]))
|
|
.build();
|
|
dialog.add_response("cancel", &gettext("Cancel"));
|
|
dialog.add_response("reset", &gettext("Sync From Scratch"));
|
|
dialog.set_response_appearance("reset", adw::ResponseAppearance::Suggested);
|
|
dialog.set_default_response(Some("cancel"));
|
|
dialog.set_close_response("cancel");
|
|
let win = ui_window(ui);
|
|
let ui = ui.clone();
|
|
dialog.connect_response(None, move |_, response| {
|
|
if response == "reset" {
|
|
reset_sync_folder(&ui, id);
|
|
}
|
|
});
|
|
dialog.present(win.as_ref());
|
|
}
|
|
|
|
/// Forget a folder's sync records, then repaint so its row shows the new pass.
|
|
fn reset_sync_folder(ui: &Rc<Ui>, id: i64) {
|
|
let rx = spawn_request(ui.dirs.control_socket(), Request::ResetSyncFolder { id });
|
|
let ui = ui.clone();
|
|
glib::spawn_future_local(async move {
|
|
let failed = gettext("Couldn't sync the folder from scratch");
|
|
match rx.recv().await {
|
|
Ok(Ok(Response::Ok { .. })) => toast(&ui, &gettext("Syncing folder…")),
|
|
Ok(Ok(Response::Error { message, kind })) => {
|
|
toast_failure(&ui, &failed, &message, kind)
|
|
}
|
|
_ => toast_error(
|
|
&ui,
|
|
&failed,
|
|
&gettext("The Proton Drive service didn't respond."),
|
|
),
|
|
}
|
|
refresh_locations(&ui);
|
|
});
|
|
}
|
|
|
|
/// Ask before making a synced folder online only: the switch frees disk space
|
|
/// by removing the local copies, which is not something to do by accident.
|
|
fn confirm_online_only(ui: &Rc<Ui>, id: i64, path: &str) {
|
|
let dialog = adw::AlertDialog::builder()
|
|
.heading(gettext("Make Folder Online Only?"))
|
|
// Translators: {path} is a local folder path.
|
|
.body(gettext_f("Files in {path} will be removed from this computer and kept in Proton Drive only. Each file downloads again when you open it.", &[("path", path)]))
|
|
.build();
|
|
dialog.add_response("cancel", &gettext("Cancel"));
|
|
dialog.add_response("ondemand", &gettext("Make Online Only"));
|
|
dialog.set_response_appearance("ondemand", adw::ResponseAppearance::Destructive);
|
|
dialog.set_default_response(Some("cancel"));
|
|
dialog.set_close_response("cancel");
|
|
let win = ui_window(ui);
|
|
let ui = ui.clone();
|
|
dialog.connect_response(None, move |_, response| {
|
|
if response == "ondemand" {
|
|
set_sync_folder_mode(&ui, id, "ondemand");
|
|
}
|
|
});
|
|
dialog.present(win.as_ref());
|
|
}
|
|
|
|
/// Ask before making an online-only folder synced: every file downloads, which
|
|
/// can fill the disk. The free space is read from the folder's parent, which is
|
|
/// on the local disk; the folder itself is a FUSE mount that may not answer.
|
|
fn confirm_synced(ui: &Rc<Ui>, id: i64, path: &str) {
|
|
let ui = ui.clone();
|
|
let path = path.to_string();
|
|
glib::spawn_future_local(async move {
|
|
let parent = Path::new(&path)
|
|
.parent()
|
|
.map(gio::File::for_path)
|
|
.unwrap_or_else(|| gio::File::for_path(&path));
|
|
let free = parent
|
|
.query_filesystem_info_future(
|
|
gio::FILE_ATTRIBUTE_FILESYSTEM_FREE,
|
|
glib::Priority::DEFAULT,
|
|
)
|
|
.await
|
|
.ok()
|
|
.map(|info| info.attribute_uint64(gio::FILE_ATTRIBUTE_FILESYSTEM_FREE));
|
|
let body = match free {
|
|
// Translators: {path} is a local folder path, {free} the free disk space such as "12.3 GiB".
|
|
Some(free) => gettext_f(
|
|
"Every file in {path} will download to this computer and stay synced. {free} is free on this disk.",
|
|
&[("path", &path), ("free", &human_bytes(free))],
|
|
),
|
|
// Translators: {path} is a local folder path.
|
|
None => gettext_f(
|
|
"Every file in {path} will download to this computer and stay synced.",
|
|
&[("path", &path)],
|
|
),
|
|
};
|
|
let dialog = adw::AlertDialog::builder()
|
|
.heading(gettext("Download Folder?"))
|
|
.body(body)
|
|
.build();
|
|
dialog.add_response("cancel", &gettext("Cancel"));
|
|
dialog.add_response("mirror", &gettext("Download"));
|
|
dialog.set_response_appearance("mirror", adw::ResponseAppearance::Suggested);
|
|
dialog.set_default_response(Some("mirror"));
|
|
dialog.set_close_response("cancel");
|
|
let win = ui_window(&ui);
|
|
let ui = ui.clone();
|
|
dialog.connect_response(None, move |_, response| {
|
|
if response == "mirror" {
|
|
set_sync_folder_mode(&ui, id, "mirror");
|
|
}
|
|
});
|
|
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 => gettext("Couldn't pause the folder"),
|
|
false => gettext("Couldn't resume the folder"),
|
|
};
|
|
match rx.recv().await {
|
|
Ok(Ok(Response::Ok { .. })) => toast(
|
|
&ui,
|
|
&match paused {
|
|
true => gettext("Folder paused"),
|
|
false => gettext("Folder resumed"),
|
|
},
|
|
),
|
|
Ok(Ok(Response::Error { message, kind })) => {
|
|
toast_failure(&ui, &failed, &message, kind)
|
|
}
|
|
_ => toast_error(
|
|
&ui,
|
|
&failed,
|
|
&gettext("The Proton Drive 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) });
|
|
let ui = ui.clone();
|
|
glib::spawn_future_local(async move {
|
|
match rx.recv().await {
|
|
Ok(Ok(Response::Ok { .. })) => toast(&ui, &gettext("Syncing folder…")),
|
|
Ok(Ok(Response::Error { message, kind })) => {
|
|
toast_failure(&ui, &gettext("Couldn't sync"), &message, kind)
|
|
}
|
|
_ => toast_error(
|
|
&ui,
|
|
&gettext("Couldn't sync"),
|
|
&gettext("The Proton Drive service didn't respond."),
|
|
),
|
|
}
|
|
});
|
|
}
|
|
|
|
pub(crate) fn location_icon(kind: &MountKind) -> &'static str {
|
|
match kind {
|
|
MountKind::MyFiles => "folder-remote-symbolic",
|
|
MountKind::Device { .. } => "folder-symbolic",
|
|
MountKind::Shared { .. } => "pdfs-people-symbolic",
|
|
}
|
|
}
|
|
|
|
/// One line describing what a location *is* and what it is doing.
|
|
///
|
|
/// Ordered by how likely it is to be what the user came to check: a queued mode
|
|
/// switch leads (they just asked for it), then the resting mode, then the sync
|
|
/// state, then — only when it is surprising — the fact that no session owns the
|
|
/// path. A mirror folder is a plain directory with no FUSE session, so having
|
|
/// none is its normal state and saying so would be noise.
|
|
pub(crate) fn location_subtitle(spec: &MountSpec) -> String {
|
|
let mut parts: Vec<String> = Vec::new();
|
|
match &spec.kind {
|
|
MountKind::MyFiles => parts.push(gettext("My files")),
|
|
MountKind::Shared { .. } => parts.push(gettext("Shared folder")),
|
|
MountKind::Device { .. } => match (spec.pending_mode, spec.mode) {
|
|
(Some(MountMode::OnDemand), _) => parts.push(gettext("Going online only")),
|
|
(Some(MountMode::Mirror), _) => parts.push(gettext("Switching to synced")),
|
|
(Some(MountMode::Unknown) | None, MountMode::OnDemand) => {
|
|
parts.push(folder_mode_label(true))
|
|
}
|
|
(Some(MountMode::Unknown) | None, _) => parts.push(folder_mode_label(false)),
|
|
},
|
|
}
|
|
if matches!(spec.kind, MountKind::Device { .. }) {
|
|
parts.push(match &spec.progress {
|
|
Some(p) => sync_progress_label(p),
|
|
None if spec.paused => pgettext("state", "paused"),
|
|
None => sync_state_label(
|
|
&spec.state,
|
|
spec.mode == MountMode::Mirror && spec.last_sync == 0,
|
|
),
|
|
});
|
|
}
|
|
// Only the locations that are supposed to have a session report its absence:
|
|
// a mirror folder never has one.
|
|
let expects_session = !matches!(spec.kind, MountKind::Device { .. })
|
|
|| spec.mode == MountMode::OnDemand
|
|
|| spec.pending_mode == Some(MountMode::OnDemand);
|
|
if expects_session && !spec.mounted {
|
|
// Translators: a folder's files can't be opened right now.
|
|
parts.push(gettext("not available"));
|
|
}
|
|
parts.join(" · ")
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn spec(kind: MountKind, mode: MountMode, mounted: bool) -> MountSpec {
|
|
MountSpec {
|
|
id: 1,
|
|
kind,
|
|
local_path: "/home/u/ProtonDrive".into(),
|
|
root_uid: "vol~link".into(),
|
|
root_share_id: "share".into(),
|
|
mode,
|
|
access: MountAccess::Rw,
|
|
state: "idle".into(),
|
|
last_sync: 0,
|
|
pending_mode: None,
|
|
mounted,
|
|
progress: None,
|
|
paused: false,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_mirror_folder_is_never_reported_as_unmounted() {
|
|
// It is a plain local directory: no FUSE session is expected, so saying
|
|
// "not mounted" would describe every healthy mirror folder as broken.
|
|
let spec = spec(
|
|
MountKind::Device { sync_folder_id: 7 },
|
|
MountMode::Mirror,
|
|
false,
|
|
);
|
|
assert_eq!(location_subtitle(&spec), "Synced · waiting for first sync");
|
|
}
|
|
|
|
/// A folder whose first pass has not run is not up to date, though its
|
|
/// state column starts out `idle` (`docs/BUGS.md` B261).
|
|
#[test]
|
|
fn a_folder_reads_up_to_date_only_after_its_first_sync() {
|
|
let mut spec = spec(
|
|
MountKind::Device { sync_folder_id: 7 },
|
|
MountMode::Mirror,
|
|
false,
|
|
);
|
|
assert_eq!(location_subtitle(&spec), "Synced · waiting for first sync");
|
|
spec.last_sync = 1_700_000_000;
|
|
assert_eq!(location_subtitle(&spec), "Synced · up to date");
|
|
// A folder that failed before it ever synced says so.
|
|
spec.last_sync = 0;
|
|
spec.state = "error".into();
|
|
assert_eq!(location_subtitle(&spec), "Synced · sync error");
|
|
}
|
|
|
|
#[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(
|
|
MountKind::Device { sync_folder_id: 7 },
|
|
MountMode::OnDemand,
|
|
false,
|
|
);
|
|
assert_eq!(
|
|
location_subtitle(&spec),
|
|
"Online only · up to date · not available"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_queued_switch_leads_the_subtitle() {
|
|
let mut spec = spec(
|
|
MountKind::Device { sync_folder_id: 7 },
|
|
MountMode::Mirror,
|
|
true,
|
|
);
|
|
spec.pending_mode = Some(MountMode::OnDemand);
|
|
assert!(location_subtitle(&spec).starts_with("Going online only"));
|
|
spec.mode = MountMode::OnDemand;
|
|
spec.pending_mode = Some(MountMode::Mirror);
|
|
assert!(location_subtitle(&spec).starts_with("Switching to synced"));
|
|
}
|
|
|
|
/// An unrecognised persisted mode must not silently read as "Synced" *and*
|
|
/// must not lose the rest of the line.
|
|
#[test]
|
|
fn the_primary_mount_reports_only_its_own_facts() {
|
|
let mut spec = spec(MountKind::MyFiles, MountMode::OnDemand, true);
|
|
assert_eq!(location_subtitle(&spec), "My files");
|
|
spec.mounted = false;
|
|
assert_eq!(location_subtitle(&spec), "My files · not available");
|
|
}
|
|
|
|
#[test]
|
|
fn sync_state_reaches_the_subtitle() {
|
|
let mut spec = spec(
|
|
MountKind::Device { sync_folder_id: 2 },
|
|
MountMode::Mirror,
|
|
true,
|
|
);
|
|
spec.state = "conflict".into();
|
|
assert_eq!(location_subtitle(&spec), "Synced · needs attention");
|
|
}
|
|
}
|