Files
proton-drive-linux/crates/pdfs-gui/src/app/pages/devices.rs
T
nvrl 437d14b0f5 fix(gui): say a folder is waiting for its first sync, not up to date
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.
2026-10-31 10:30:52 +01:00

1418 lines
54 KiB
Rust

use crate::*;
pub(crate) struct DevicesState {
// Computers page: one list of the account's computers, this one first, and
// a read-only browser over another computer's backup.
/// "list", "browse", or "status" while the page loads or can't be read.
pub(crate) content: gtk4::Stack,
pub(crate) status: adw::StatusPage,
pub(crate) retry: gtk4::Button,
pub(crate) group: adw::PreferencesGroup,
/// Rows added last time, removed before the next paint (adw groups have no
/// clear-all).
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
/// The browse view's files.
pub(crate) files: FileList,
/// The browse view: "list", or "empty".
pub(crate) files_content: gtk4::Stack,
/// The breadcrumb trail from "Computers" down to the current folder.
pub(crate) crumb: gtk4::Box,
/// Grid or list for the browse view; hidden on the device list.
pub(crate) layout: gtk4::Button,
/// Where the browse view is, as `(uid, name)`: the computer first, then each
/// folder below it. Empty = the device list. A backup has no path in the
/// mount, so descending is uid-addressed and the stack is the breadcrumb.
pub(crate) nav: RefCell<Vec<(String, String)>>,
/// The [`DevicesState::nav`] the page on screen was painted for, or `None`
/// before the first paint.
pub(crate) listed: RefCell<Option<Vec<(String, String)>>>,
/// Runs the loads; the rows stay up while one runs.
pub(crate) loader: Rc<Loader>,
}
impl DevicesState {
pub(crate) fn new(widgets: &DevicesWidgets) -> Self {
DevicesState {
content: widgets.content.clone(),
status: widgets.status.clone(),
retry: widgets.retry.clone(),
group: widgets.group.clone(),
rows: RefCell::new(Vec::new()),
files: widgets.files.clone(),
files_content: widgets.files_content.clone(),
crumb: widgets.crumb.clone(),
layout: widgets.layout.clone(),
nav: RefCell::new(Vec::new()),
listed: RefCell::new(None),
loader: Loader::new(&widgets.content),
}
}
}
/// Widgets the Devices page's load/repaint touch.
pub(crate) struct DevicesWidgets {
pub(crate) content: gtk4::Stack,
pub(crate) status: adw::StatusPage,
/// Every computer on the account, this one first.
pub(crate) group: adw::PreferencesGroup,
pub(crate) files: FileList,
pub(crate) files_content: gtk4::Stack,
/// Goes up one folder, and from a computer's top level back to the list.
pub(crate) back: gtk4::Button,
pub(crate) crumb: gtk4::Box,
pub(crate) layout: gtk4::Button,
pub(crate) retry: gtk4::Button,
}
/// The Computers page: every computer backing up to the account, with this one
/// first. This computer's row leads to its folders on the Sync page; another
/// computer's row opens its backup read-only, in the same list as My files.
pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
let group = adw::PreferencesGroup::new();
let clamp = adw::Clamp::builder().child(&group).build();
let scroll = gtk4::ScrolledWindow::builder()
.vexpand(true)
.child(&clamp)
.build();
// Browse: a path bar over the list, as on Shared with me.
let files = FileList::new();
files.show_list(true);
let layout = layout_button(&files);
layout.set_visible(false);
let back = gtk4::Button::builder()
.icon_name("go-previous-symbolic")
.tooltip_text(gettext("Back"))
.build();
back.add_css_class("flat");
let crumb = gtk4::Box::new(gtk4::Orientation::Horizontal, 2);
crumb.set_valign(gtk4::Align::Center);
let crumb_scroll = gtk4::ScrolledWindow::builder()
.hscrollbar_policy(gtk4::PolicyType::External)
.vscrollbar_policy(gtk4::PolicyType::Never)
.hexpand(true)
.child(&crumb)
.build();
let path_bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
path_bar.append(&back);
path_bar.append(&crumb_scroll);
let files_empty = adw::StatusPage::builder()
.icon_name("folder-open-symbolic")
.title(gettext("This folder is empty"))
.description(gettext("Nothing in this folder has been backed up."))
.vexpand(true)
.build();
files_empty.add_css_class("compact");
let files_content = gtk4::Stack::new();
files_content.add_named(&files.views, Some("list"));
files_content.add_named(&files_empty, Some("empty"));
let browse = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
browse.append(&path_bar);
browse.append(&files_content);
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("computer-symbolic")
.vexpand(true)
.child(&retry)
.build();
status.add_css_class("compact");
let content = gtk4::Stack::new();
content.set_vexpand(true);
content.set_transition_type(gtk4::StackTransitionType::Crossfade);
content.add_named(&scroll, Some("list"));
content.add_named(&browse, Some("browse"));
content.add_named(&status, Some("status"));
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
inner.set_margin_top(18);
inner.set_margin_bottom(18);
inner.set_margin_start(18);
inner.set_margin_end(18);
inner.append(&content);
let (frame, header, _) = page_frame(&gettext("Computers"), &inner);
header.pack_end(&layout);
(
frame.upcast(),
DevicesWidgets {
content,
status,
group,
files,
files_content,
back,
crumb,
layout,
retry,
},
)
}
/// Install the browse view's file list and the page's buttons.
pub(crate) fn wire_devices(ui: &Rc<Ui>, widgets: &DevicesWidgets) {
let files = &ui.devices.files;
files.wire(
ui,
FileListBehavior {
activate: open_device_entry,
entry_menu: device_entry_menu,
bulk_menu: device_bulk_menu,
background_menu: device_background_menu,
badges: false,
drag_and_drop: false,
},
);
files
.column_view
.append_column(&text_column(&pgettext("column", "Modified"), |e| {
if e.modified > 0 {
dates::short_date(e.modified)
} else {
"—".to_string()
}
}));
files
.column_view
.append_column(&text_column(&pgettext("column", "Size"), |e| {
if e.is_dir {
"—".to_string()
} else {
human_bytes(e.size)
}
}));
let ui_back = ui.clone();
widgets.back.connect_clicked(move |_| {
ui_back.devices.nav.borrow_mut().pop();
load_devices(&ui_back);
});
let ui_retry = ui.clone();
widgets
.retry
.connect_clicked(move |_| restart_service_then(&ui_retry, load_devices));
}
/// Show a status page in place of the Devices list.
pub(crate) fn devices_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &str, retry: bool) {
ui.devices.status.set_icon_name(Some(icon));
ui.devices.status.set_title(title);
ui.devices
.status
.set_description(Some(&gtk4::glib::markup_escape_text(description)));
ui.devices.retry.set_visible(retry);
ui.devices.content.set_visible_child_name("status");
}
/// Load the view [`DevicesState::nav`] points at: the device list, or a folder
/// of another computer's backup.
pub(crate) fn load_devices(ui: &Rc<Ui>) {
cancel_file_thumbnails(ui);
let nav = ui.devices.nav.borrow().clone();
ui.devices.layout.set_visible(!nav.is_empty());
if !nav.is_empty() {
load_device_folder(ui, &nav);
return;
}
let ticket = begin_devices_load(ui, "computer-symbolic", gettext("Reading your computers."));
ui.busy_begin();
// The two lists are independent and the daemon serves requests concurrently,
// so fire both up front and collect them, rather than paying two round trips
// back to back.
let rx = spawn_request(ui.dirs.control_socket(), Request::ListSyncFolders);
let rx2 = spawn_request(ui.dirs.control_socket(), Request::ListDevices);
let ui = ui.clone();
glib::spawn_future_local(async move {
let sync = rx.recv().await;
let devices_reply = rx2.recv().await;
ui.busy_end();
if !ticket.is_current() {
return;
}
let folders = match sync {
Ok(Ok(Response::SyncFolders { items })) => items,
// The daemon answered but not as expected — treat as empty and carry
// on to the devices list rather than failing the whole page.
Ok(Ok(_)) => Vec::new(),
Ok(Err(_)) | Err(_) => {
devices_unreachable(&ui);
return;
}
};
let devices = match devices_reply {
Ok(Ok(Response::Devices { items })) => items,
_ => Vec::new(),
};
ui.devices.content.set_visible_child_name("list");
repaint_devices(&ui, &devices, &folders);
repaint_device_crumb(&ui, &[]);
*ui.devices.listed.borrow_mut() = Some(Vec::new());
});
}
/// List one folder of another computer's backup. The computer's own top level
/// is its restorable folders; below that, any folder lists by uid.
fn load_device_folder(ui: &Rc<Ui>, nav: &[(String, String)]) {
let ticket = begin_devices_load(ui, "folder-symbolic", gettext("Reading this folder."));
ui.busy_begin();
let (uid, _) = nav.last().cloned().unwrap_or_default();
let request = if nav.len() == 1 {
Request::ListDeviceRestorableFolders { device: uid }
} else {
Request::ListSharedFolder { uid }
};
let rx = spawn_request(ui.dirs.control_socket(), request);
let ui = ui.clone();
let nav = nav.to_vec();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.busy_end();
if !ticket.is_current() {
return;
}
let entries = match result {
Ok(Ok(Response::Entries { entries })) => entries,
Ok(Ok(Response::RestorableFolders { items })) => {
items.into_iter().map(restorable_entry).collect()
}
Ok(Ok(Response::Error { message, kind })) => {
// The folder or the computer is gone: fall back a level rather
// than stranding the page on a dead uid.
ui.devices.nav.borrow_mut().pop();
toast_failure(&ui, &gettext("Couldn't open folder"), &message, kind);
load_devices(&ui);
return;
}
_ => {
devices_unreachable(&ui);
return;
}
};
ui.devices.content.set_visible_child_name("browse");
repaint_device_crumb(&ui, &nav);
*ui.devices.listed.borrow_mut() = Some(nav);
if entries.is_empty() {
ui.devices.files.model.remove_all();
ui.devices.files_content.set_visible_child_name("empty");
} else {
ui.devices.files_content.set_visible_child_name("list");
replace_items(&ui.devices.files.model, &entries);
}
});
}
/// A computer's top-level folder as a browsable entry.
fn restorable_entry(folder: RestorableFolder) -> DirEntry {
DirEntry {
name: folder.name,
is_dir: true,
uid: folder.remote_uid,
..DirEntry::default()
}
}
/// Start a load of the view [`DevicesState::nav`] points at. A reload of the
/// view on screen keeps it up and usable; moving elsewhere greys it out until
/// the new view arrives.
fn begin_devices_load(ui: &Rc<Ui>, icon: &'static str, description: String) -> LoadTicket {
let ui_p = ui.clone();
let placeholder = move || {
devices_status(&ui_p, icon, &gettext("Loading…"), &description, false);
};
if *ui.devices.listed.borrow() == Some(ui.devices.nav.borrow().clone()) {
ui.devices.loader.refresh(placeholder)
} else {
ui.devices.listed.borrow_mut().take();
ui.devices.loader.replace(placeholder)
}
}
/// The daemon didn't answer the Devices page.
pub(crate) fn devices_unreachable(ui: &Rc<Ui>) {
service_unreachable(ui, "devices", devices_status, load_devices);
}
/// Rebuild the device list: this computer first, then the others.
///
/// This computer's row offers no Remove. Deleting a device deletes its root
/// folder — every file backed up from it — and for this machine that would also
/// pull the ground out from under its synced folders, so it is not offered as a
/// peer of "remove some other laptop". Removing this computer's backup means
/// removing its folders, each of which asks about its cloud copy on its own
/// terms.
pub(crate) fn repaint_devices(ui: &Rc<Ui>, devices: &[DeviceInfo], folders: &[SyncFolderInfo]) {
for row in ui.devices.rows.borrow_mut().drain(..) {
ui.devices.group.remove(&row);
}
let mut rows: Vec<gtk4::Widget> = Vec::new();
let me = devices.iter().find(|d| d.this_device);
rows.push(this_computer_row(ui, me, folders).upcast());
let others: Vec<&DeviceInfo> = devices.iter().filter(|d| !d.this_device).collect();
for dev in &others {
rows.push(other_computer_row(ui, dev).upcast());
}
if others.is_empty() {
let row = adw::ActionRow::builder()
.title(gettext("No other computers"))
.subtitle(gettext(
"Install Proton Drive on another computer to see it here.",
))
.build();
row.add_prefix(&gtk4::Image::from_icon_name("computer-symbolic"));
let get = gtk4::LinkButton::with_label(PROTON_DRIVE_DOWNLOAD, &gettext("Get Proton Drive"));
get.set_valign(gtk4::Align::Center);
row.add_suffix(&get);
rows.push(row.upcast());
}
for row in &rows {
ui.devices.group.add(row);
}
*ui.devices.rows.borrow_mut() = rows;
}
/// Where the Proton Drive apps for other systems are.
const PROTON_DRIVE_DOWNLOAD: &str = "https://proton.me/drive/download";
/// This computer's row: its device name with a "This computer" pill, and how
/// many folders it backs up. It leads to those folders on the Sync page, which
/// owns every local path. `me` is `None` until the daemon has registered this
/// machine as a device.
fn this_computer_row(
ui: &Rc<Ui>,
me: Option<&DeviceInfo>,
folders: &[SyncFolderInfo],
) -> adw::ActionRow {
let row = adw::ActionRow::builder()
.title(me.map_or_else(|| gettext("This computer"), |d| d.name.clone()))
.subtitle(this_computer_subtitle(folders))
.use_markup(false)
.activatable(true)
.build();
row.add_prefix(&gtk4::Image::from_icon_name("computer-symbolic"));
if me.is_some() {
let pill = gtk4::Label::new(Some(&gettext("This computer")));
pill.add_css_class("role-pill");
pill.add_css_class("caption");
pill.set_valign(gtk4::Align::Center);
row.add_suffix(&pill);
}
let mut menu = ActionMenu::new();
if let Some(me) = me {
let (ui_c, uid, name) = (ui.clone(), me.uid.clone(), me.name.clone());
menu.item(&gettext("Rename…"), move || {
prompt_rename_device(&ui_c, &uid, &name)
});
}
let ui_c = ui.clone();
menu.item(&gettext("Restore Folders…"), move || {
prompt_restore_folders(&ui_c, None)
});
row.add_suffix(&menu.button());
row.add_suffix(&gtk4::Image::from_icon_name("go-next-symbolic"));
let ui_c = ui.clone();
row.connect_activated(move |_| {
ui_c.locations.views.set_visible_child_name("folders");
ui_c.stack.set_visible_child_name("locations");
});
row
}
/// Another computer's row. Opening it browses that computer's backup.
fn other_computer_row(ui: &Rc<Ui>, dev: &DeviceInfo) -> adw::ActionRow {
let row = adw::ActionRow::builder()
.title(&dev.name)
.subtitle(device_subtitle(dev))
.use_markup(false)
.activatable(true)
.build();
row.add_prefix(&gtk4::Image::from_icon_name("computer-symbolic"));
let (uid, name) = (dev.uid.clone(), dev.name.clone());
let mut menu = ActionMenu::new();
let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone());
menu.item(&gettext("Browse Files"), move || {
browse_device(&ui_c, &uid_c, &name_c)
});
let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone());
menu.item(&gettext("Restore to This Computer…"), move || {
prompt_restore_folders(&ui_c, Some((uid_c.clone(), name_c.clone())))
});
let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone());
menu.item(&gettext("Sync With This Computer…"), move || {
prompt_join_device(&ui_c, &uid_c, &name_c)
});
let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone());
menu.item(&gettext("Move to This Computer…"), move || {
prompt_migrate_device(&ui_c, &uid_c, &name_c)
});
menu.section();
let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone());
menu.item(&gettext("Rename…"), move || {
prompt_rename_device(&ui_c, &uid_c, &name_c)
});
// Adoption is how a reinstalled or renamed machine re-attaches to the device
// it used to be, instead of registering a duplicate. It only makes sense on
// another computer's row.
let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone());
menu.item(&gettext("Continue This Backup Here…"), move || {
prompt_adopt_device(&ui_c, &uid_c, &name_c)
});
menu.section();
let (ui_c, uid_c, name_c) = (ui.clone(), uid.clone(), name.clone());
menu.item(&gettext("Remove…"), move || {
prompt_remove_device(&ui_c, &uid_c, &name_c)
});
row.add_suffix(&menu.button());
row.add_suffix(&gtk4::Image::from_icon_name("go-next-symbolic"));
let ui_c = ui.clone();
row.connect_activated(move |_| browse_device(&ui_c, &uid, &name));
row
}
/// Open another computer's backup at its top level.
fn browse_device(ui: &Rc<Ui>, uid: &str, name: &str) {
*ui.devices.nav.borrow_mut() = vec![(uid.to_string(), name.to_string())];
load_devices(ui);
}
/// Rebuild the breadcrumb trail for `nav`: "Computers", then the computer and
/// each folder down to the current one, which is a plain heading.
fn repaint_device_crumb(ui: &Rc<Ui>, nav: &[(String, String)]) {
let crumb = &ui.devices.crumb;
while let Some(child) = crumb.first_child() {
crumb.remove(&child);
}
crumb.append(&device_crumb_node(
ui,
&gettext("Computers"),
0,
nav.is_empty(),
));
for (i, (_, name)) in nav.iter().enumerate() {
crumb.append(&crumb_separator());
crumb.append(&device_crumb_node(ui, name, i + 1, i + 1 == nav.len()));
}
}
/// One breadcrumb segment: a heading for the current folder, or a flat button
/// that goes back up to `depth` levels below the device list.
fn device_crumb_node(ui: &Rc<Ui>, label: &str, depth: usize, current: bool) -> gtk4::Widget {
if current {
return crumb_current(label);
}
let button = gtk4::Button::builder().label(label).build();
button.add_css_class("flat");
let ui = ui.clone();
button.connect_clicked(move |_| {
ui.devices.nav.borrow_mut().truncate(depth);
load_devices(&ui);
});
button.upcast()
}
/// Folders open in place; files download and open with the default app.
fn open_device_entry(ui: &Rc<Ui>, entry: &DirEntry) {
if entry.is_dir {
ui.devices
.nav
.borrow_mut()
.push((entry.uid.clone(), entry.name.clone()));
load_devices(ui);
} else {
open_shared_file(ui, &entry.uid, &entry.name);
}
}
fn device_entry_menu(ui: &Rc<Ui>, entry: &DirEntry) -> ActionMenu {
let mut menu = ActionMenu::new();
let (ui_c, entry_c) = (ui.clone(), entry.clone());
menu.item(&pgettext("verb", "Open"), move || {
open_device_entry(&ui_c, &entry_c)
});
menu
}
fn device_bulk_menu(_ui: &Rc<Ui>, entries: Vec<DirEntry>) -> ActionMenu {
let mut menu = ActionMenu::new();
menu.labelled_section(&ngettext_f(
"{n} selected",
"{n} selected",
entries.len() as u64,
&[],
));
menu
}
fn device_background_menu(ui: &Rc<Ui>) -> ActionMenu {
let mut menu = ActionMenu::new();
let ui_c = ui.clone();
menu.item(&gettext("Select All"), move || {
ui_c.devices.files.selection.select_all();
});
let ui_c = ui.clone();
menu.item(&gettext("Refresh"), move || reload_current_page(&ui_c));
menu
}
/// A device row's subtitle: its platform, and when it last backed up. A device
/// that never did says so — an unexplained missing date reads as a bug, and
/// "no backups yet" is the fact that tells the user this computer isn't backing
/// anything up.
pub(crate) fn device_subtitle(dev: &DeviceInfo) -> String {
let platform = platform_label(&dev.device_type);
match dev.last_sync {
// Translators: {platform} is the device's platform (such as "Linux"), {time} a relative time such as "5 minutes ago".
Some(secs) if secs > 0 => gettext_f(
"{platform} · last backup {time}",
&[("platform", platform), ("time", &dates::relative(secs))],
),
// Translators: {platform} is the device's platform, such as "Linux".
_ => gettext_f("{platform} · no backups yet", &[("platform", platform)]),
}
}
/// The platform as its maker spells it. The API's names are enum variants.
fn platform_label(device_type: &str) -> &str {
match device_type {
"MacOs" => "macOS",
other => other,
}
}
/// How many folders this machine backs up, and whether any of them needs
/// attention — the one fact worth surfacing away from the folder list itself.
pub(crate) fn this_computer_subtitle(folders: &[SyncFolderInfo]) -> String {
if folders.is_empty() {
return gettext("No folders backed up yet");
}
let count = ngettext_f(
"{n} folder backed up",
"{n} folders backed up",
folders.len() as u64,
&[],
);
// A folder whose first pass has not run is not backed up yet, whatever its
// state column says (`docs/BUGS.md` B261).
let waiting = folders.iter().filter(|f| never_synced(f)).count();
let count = match waiting {
0 => count,
// Translators: {folders} is "N folders backed up"; {n} counts the folders whose first sync has not run yet.
n => ngettext_f(
"{folders} · {n} waiting for its first sync",
"{folders} · {n} waiting for their first sync",
n as u64,
&[("folders", &count)],
),
};
let attention = folders
.iter()
.filter(|f| f.state == "error" || f.state == "conflict")
.count();
match attention {
0 => count,
// Translators: {folders} is "N folders backed up"; {n} counts the folders with a problem.
n => ngettext_f(
"{folders} · {n} needs attention",
"{folders} · {n} need attention",
n as u64,
&[("folders", &count)],
),
}
}
/// Whether a synced folder is a mirror whose first pass has not finished.
/// Its `state` starts out `idle`, which would read as "up to date".
fn never_synced(folder: &SyncFolderInfo) -> bool {
folder.mode == "mirror" && folder.last_sync == 0 && folder.state == "idle"
}
/// Human label for a synced folder's `state` column. `never_synced` is a
/// mirror folder whose first pass has not finished: its `state` starts out
/// `idle`, and "up to date" would say the opposite of the truth (B261).
pub(crate) fn sync_state_label(state: &str, never_synced: bool) -> String {
match state {
"syncing" => gettext("syncing…"),
"error" => gettext("sync error"),
"conflict" => gettext("needs attention"),
_ if never_synced => gettext("waiting for first sync"),
_ => gettext("up to date"),
}
}
/// Human label for a sync pass in flight: what it is doing, to which file, and
/// how far along it is. Neither phase's total is exact — the scan's is an estimate
/// from the last pass, and the applying total grows as deeper paths are classified
/// — so both read "12 of 40" rather than a percentage that could go backwards.
pub(crate) fn sync_progress_label(p: &SyncProgress) -> String {
match p.phase {
SyncPhase::Scanning if p.total == 0 && p.done == 0 => gettext("checking for changes…"),
// Before the first pass finishes there is no estimate, so "12 of 12" would
// say nothing. The count alone still shows a first scan of a large folder
// moving, which can take many minutes before anything downloads.
// Translators: {n} counts the folders and files checked so far.
SyncPhase::Scanning if p.total == 0 => ngettext_f(
"checking for changes — {n} item checked",
"checking for changes — {n} items checked",
p.done as u64,
&[],
),
// Translators: {done} and {total} count folders and files checked so far.
SyncPhase::Scanning => gettext_f(
"checking for changes — {done} of {total}",
&[
("done", &p.done.to_string()),
("total", &p.total.max(p.done).to_string()),
],
),
SyncPhase::Applying => {
let done = (p.done + 1).to_string();
let total = p.total.max(p.done + 1).to_string();
if p.current.is_empty() {
// Translators: {done} and {total} count the changes applied so far.
gettext_f(
"syncing {done} of {total}",
&[("done", &done), ("total", &total)],
)
} else {
// Translators: {file} is the path being synced; {done} and {total} count the changes applied so far.
gettext_f(
"syncing {file} — {done} of {total}",
&[("file", &p.current), ("done", &done), ("total", &total)],
)
}
}
}
}
/// Pick a local folder and hand it to the daemon to sync to this device.
pub(crate) fn prompt_add_sync_folder(ui: &Rc<Ui>) {
let win = ui_window(ui);
let dialog = gtk4::FileDialog::builder()
.title(gettext("Add Folder to Sync"))
.build();
let ui = ui.clone();
dialog.select_folder(win.as_ref(), gio::Cancellable::NONE, move |res| {
let Ok(folder) = res else { return };
let Some(local_path) = folder.path().and_then(|p| p.to_str().map(str::to_string)) else {
return;
};
let rx = spawn_request(
ui.dirs.control_socket(),
Request::AddSyncFolder {
local_path: local_path.clone(),
},
);
let ui = ui.clone();
glib::spawn_future_local(async move {
match rx.recv().await {
Ok(Ok(Response::Ok { .. })) => {
// The daemon acks before the row exists — registering the
// device and creating the remote folder is off-socket network
// work. A fixed delay here either fires too early (empty list)
// or too late (row flashes in). Instead let the periodic
// `refresh_locations` tick pick the row up whenever it
// actually lands, which is what keeps a running pass live too.
toast(&ui, &gettext("Syncing folder…"));
}
Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, &gettext("Couldn't add folder"), &message, kind)
}
_ => toast_error(
&ui,
&gettext("Couldn't add folder"),
&gettext("The Proton Drive service didn't respond."),
),
}
});
});
}
/// Ask the daemon what a device holds, then show a picker mapping each remote
/// folder onto a local directory. `device` is another computer as `(uid, name)`;
/// `None` is this machine's own device.
///
/// The daemon's paths are proposals — from the device's `profile.json` when it
/// makes sense here, else `~/<name>` — so every one of them is editable and
/// nothing is restored without being ticked.
pub(crate) fn prompt_restore_folders(ui: &Rc<Ui>, device: Option<(String, String)>) {
ui.busy_begin();
let request = match &device {
Some((uid, _)) => Request::ListDeviceRestorableFolders {
device: uid.clone(),
},
None => Request::ListRestorableFolders,
};
let rx = spawn_request(ui.dirs.control_socket(), request);
let ui = ui.clone();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.busy_end();
match result {
Ok(Ok(Response::RestorableFolders { items })) => {
show_restore_picker(&ui, items, device)
}
Ok(Ok(Response::Error { message, kind })) => toast_failure(
&ui,
&gettext("Couldn't list folders to restore"),
&message,
kind,
),
_ => toast_error(
&ui,
&gettext("Couldn't list folders to restore"),
&gettext("The Proton Drive service didn't respond."),
),
}
});
}
/// The restore picker itself: one editable row per restorable folder.
fn show_restore_picker(
ui: &Rc<Ui>,
items: Vec<RestorableFolder>,
device: Option<(String, String)>,
) {
let win = ui_window(ui);
let candidates: Vec<RestorableFolder> =
items.into_iter().filter(|f| !f.already_synced).collect();
if candidates.is_empty() {
toast(
ui,
&nothing_to_restore(device.as_ref().map(|(_, n)| n.as_str())),
);
return;
}
let group = adw::PreferencesGroup::builder()
.description(gettext(
"Tick the folders to sync to this machine, and adjust where each one should live.",
))
.build();
// Held so the response handler can read back what the user ticked and typed.
let mut controls: Vec<(String, String, gtk4::CheckButton, adw::EntryRow)> = Vec::new();
for f in candidates {
let check = gtk4::CheckButton::builder()
.active(true)
.valign(gtk4::Align::Center)
.build();
let row = adw::EntryRow::builder()
.title(&f.name)
.use_markup(false)
.build();
row.set_text(&f.local_path);
row.add_prefix(&check);
group.add(&row);
controls.push((f.remote_uid, f.mode, check, row));
}
let scroll = gtk4::ScrolledWindow::builder()
.propagate_natural_height(true)
.max_content_height(420)
.child(&group)
.build();
let dialog = adw::AlertDialog::builder()
.heading(gettext("Restore Folders"))
.body(restore_picker_body(
device.as_ref().map(|(_, n)| n.as_str()),
))
.extra_child(&scroll)
.build();
dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("restore", &gettext("Restore"));
dialog.set_response_appearance("restore", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("restore"));
dialog.set_close_response("cancel");
let ui = ui.clone();
dialog.connect_response(None, move |_, resp| {
if resp != "restore" {
return;
}
let items: Vec<RestoreItem> = controls
.iter()
.filter(|(_, _, check, _)| check.is_active())
.map(|(uid, mode, _, row)| RestoreItem {
remote_uid: uid.clone(),
local_path: row.text().to_string(),
mode: mode.clone(),
})
.collect();
if items.is_empty() {
return;
}
// The daemon acks before the downloads finish, like AddSyncFolder — the
// rows appear as the periodic refresh picks them up.
run_devices_mutation(
&ui,
match &device {
Some((uid, _)) => Request::RestoreDeviceFolders {
device: uid.clone(),
items,
},
None => Request::RestoreSyncFolders { items },
},
&gettext("Restoring folders…"),
&gettext("Couldn't restore folders"),
);
});
dialog.present(win.as_ref());
}
/// The restore picker's body: whose backup the folders come from.
pub(crate) fn restore_picker_body(device: Option<&str>) -> String {
match device {
// Translators: {name} is the name of another computer.
Some(name) => gettext_f(
"These folders are backed up from “{name}”. Restored folders keep syncing with that computer's copy.",
&[("name", name)],
),
None => gettext("These folders are backed up under this computer in Proton Drive."),
}
}
/// The toast when every folder of a device is already synced here.
pub(crate) fn nothing_to_restore(device: Option<&str>) -> String {
match device {
// Translators: {name} is the name of another computer.
Some(name) => gettext_f(
"Nothing to restore — “{name}”'s folders are all synced here.",
&[("name", name)],
),
None => gettext("Nothing to restore — this computer's folders are all synced here."),
}
}
/// Flip a synced folder between `mirror` and `ondemand`. Reloads after so the
/// row's subtitle and switch reflect the daemon's real state (the request may be
/// rejected, e.g. switching to on-demand while a folder is mid-sync).
pub(crate) fn set_sync_folder_mode(ui: &Rc<Ui>, id: i64, mode: &'static str) {
let rx = spawn_request(
ui.dirs.control_socket(),
Request::SetSyncFolderMode {
id,
mode: mode.to_string(),
},
);
let ui = ui.clone();
glib::spawn_future_local(async move {
match rx.recv().await {
// The daemon's own text is written for the log; the toast says what
// the person will see happen. A switch may be queued behind a running
// pass, hence "will".
Ok(Ok(Response::Ok { .. })) => toast(
&ui,
&if mode == "ondemand" {
gettext("Folder will switch to online only")
} else {
gettext("Folder will download and stay synced")
},
),
Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, &gettext("Couldn't change mode"), &message, kind)
}
_ => toast_error(
&ui,
&gettext("Couldn't change mode"),
&gettext("The Proton Drive service didn't respond."),
),
}
reload_sync_pages(&ui);
});
}
/// Repaint whichever of the two pages that show sync folders is on screen. Sync
/// folder changes are raised from Locations as well as Computers, and both must
/// show the daemon's answer at once rather than on the next tick — a rejected
/// mode switch would otherwise stay visibly flipped.
pub(crate) fn reload_sync_pages(ui: &Rc<Ui>) {
match ui.stack.visible_child_name().as_deref() {
Some("devices") => load_devices(ui),
Some("locations") => refresh_locations(ui),
_ => {}
}
}
/// Confirm, then stop syncing a folder. Offers to also delete the cloud copy.
pub(crate) fn prompt_remove_sync_folder(ui: &Rc<Ui>, id: i64, path: &str, ondemand: bool) {
let win = ui_window(ui);
// The two modes leave the user in opposite places, so they can't share a
// sentence. A mirror folder's files are already on this disk and stay there.
// An on-demand folder's are not: the path is a mount over content that lives
// in Proton Drive, so unmounting it leaves an empty directory — which is a
// nasty surprise if the dialog claimed the local files were safe, and is
// recoverable only by turning On-demand off *first* and letting it download.
let body = if ondemand {
// Translators: {path} is a local folder path.
gettext_f(
"Stop syncing “{path}”?\n\nThis folder is online only: its files live in Proton Drive, not on this disk, so the folder will be empty once it stops syncing. To keep a local copy, cancel, switch the folder to Synced, and wait for the download to finish before removing it.",
&[("path", path)],
)
} else {
// Translators: {path} is a local folder path.
gettext_f(
"Stop syncing “{path}”?\n\nThe local files stay on this disk and simply stop being synced. Choose whether to also delete the copy in Proton Drive.",
&[("path", path)],
)
};
let dialog = adw::AlertDialog::builder()
.heading(gettext("Stop Syncing Folder?"))
.body(body)
.build();
let group = adw::PreferencesGroup::new();
let delete_remote = adw::SwitchRow::builder()
.title(gettext("Also delete from Proton Drive"))
.subtitle(if ondemand {
gettext("Deletes the only copy of these files.")
} else {
gettext("The local copy is unaffected.")
})
.active(false)
.build();
group.add(&delete_remote);
dialog.set_extra_child(Some(&group));
dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("remove", &gettext("Stop Syncing"));
dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
let ui = ui.clone();
dialog.connect_response(None, move |_, resp| {
if resp == "remove" {
run_devices_mutation(
&ui,
Request::RemoveSyncFolder {
id,
delete_remote: delete_remote.is_active(),
},
&gettext("Stopped syncing folder"),
&gettext("Couldn't stop syncing the folder"),
);
}
});
dialog.present(win.as_ref());
}
/// Prompt for a new device name and rename it.
pub(crate) fn prompt_rename_device(ui: &Rc<Ui>, uid: &str, current: &str) {
let win = ui_window(ui);
let dialog = adw::AlertDialog::builder()
.heading(gettext("Rename Computer"))
// Translators: {name} is the computer's current name.
.body(gettext_f("Rename “{name}”.", &[("name", current)]))
.build();
let group = adw::PreferencesGroup::new();
let row = adw::EntryRow::builder()
.title(gettext("New name"))
.activates_default(true)
.build();
row.set_text(current);
group.add(&row);
dialog.set_extra_child(Some(&group));
dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("rename", &gettext("Rename"));
dialog.set_response_appearance("rename", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("rename"));
dialog.set_close_response("cancel");
let ui = ui.clone();
let uid = uid.to_string();
dialog.connect_response(None, move |_, resp| {
if resp != "rename" {
return;
}
let name = row.text().trim().to_string();
if name.is_empty() {
toast_error(
&ui,
&gettext("Couldn't rename the computer"),
&gettext("A name is required."),
);
return;
}
run_devices_mutation(
&ui,
Request::RenameDevice {
uid: uid.clone(),
name,
},
&gettext("Computer renamed"),
&gettext("Couldn't rename the computer"),
);
});
dialog.present(win.as_ref());
}
/// Confirm, then remove (deregister) a computer. Removing deletes everything the
/// computer backed up, so the confirm button stays off until the name is typed.
pub(crate) fn prompt_remove_device(ui: &Rc<Ui>, uid: &str, name: &str) {
let win = ui_window(ui);
let confirm = adw::EntryRow::builder()
// Translators: {name} is the computer's name, which the user must type.
.title(gettext_f("Type “{name}” to confirm", &[("name", name)]))
.build();
let group = adw::PreferencesGroup::new();
group.add(&confirm);
let dialog = adw::AlertDialog::builder()
.heading(gettext("Remove Computer?"))
// Translators: {name} is the name of the computer being removed.
.body(gettext_f(
"Remove “{name}” from this account?\n\nEverything it backed up to Proton Drive is deleted along with it. The files on that computer itself are not touched — but this cannot be undone from here.",
&[("name", name)],
))
.extra_child(&group)
.build();
dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("remove", &gettext("Remove"));
dialog.set_response_appearance("remove", adw::ResponseAppearance::Destructive);
dialog.set_response_enabled("remove", false);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
let dialog_typed = dialog.clone();
let expected = name.to_string();
confirm.connect_changed(move |row| {
dialog_typed.set_response_enabled("remove", row.text().trim() == expected);
});
let ui = ui.clone();
let uid = uid.to_string();
dialog.connect_response(None, move |_, resp| {
if resp == "remove" {
run_devices_mutation(
&ui,
Request::DeleteDevice { uid: uid.clone() },
&gettext("Computer removed"),
&gettext("Couldn't remove the computer"),
);
}
});
dialog.present(win.as_ref());
}
/// Confirm, then continue another computer's backup on this machine (adopt its
/// device).
///
/// Worth a confirmation rather than a plain click: adoption re-points this
/// machine's syncing at another computer's device folder, and the folders
/// already synced here keep pointing at the old one until they are removed. The
/// dialog says so, because the alternative is a user discovering it by watching
/// their folders diverge.
pub(crate) fn prompt_adopt_device(ui: &Rc<Ui>, uid: &str, name: &str) {
let win = ui_window(ui);
let dialog = adw::AlertDialog::builder()
.heading(gettext("Continue This Backup Here?"))
// Translators: {name} is the name of the other computer whose backup this one takes over.
.body(gettext_f(
"This computer takes over the backup of “{name}”, for example after a reinstall.\n\nNew synced folders go under “{name}” in Proton Drive, even if this computer's name changes. Folders already synced here are not moved. Use “Restore to This Computer” afterwards to bring back what “{name}” was syncing.",
&[("name", name)],
))
.build();
dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("adopt", &gettext("Continue Here"));
dialog.set_response_appearance("adopt", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
let ui = ui.clone();
let uid = uid.to_string();
// Translators: {name} is the name of the computer whose backup this one took over.
let done = gettext_f("Now backing up as “{name}”", &[("name", name)]);
dialog.connect_response(None, move |_, resp| {
if resp == "adopt" {
run_devices_mutation(
&ui,
Request::AdoptDevice {
uid: Some(uid.clone()),
},
&done,
&gettext("Couldn't continue that backup here"),
);
}
});
dialog.present(win.as_ref());
}
/// Ask before syncing into another computer's device alongside it, then offer
/// its folders for syncing here.
///
/// The same adoption as "Continue This Backup Here", but the other computer
/// goes on syncing, so the dialog says what that means: the two keep the same
/// folders in step, deletions included.
pub(crate) fn prompt_join_device(ui: &Rc<Ui>, uid: &str, name: &str) {
let win = ui_window(ui);
let dialog = adw::AlertDialog::builder()
.heading(gettext("Sync With This Computer?"))
// Translators: {name} is the name of the other computer this one syncs with.
.body(gettext_f(
"This computer and “{name}” keep the same folders in step. A change or a deletion on one reaches the other.\n\nNew synced folders on this computer go under “{name}” in Proton Drive. Folders already synced here stay where they are. Next you choose which of its folders to sync here.",
&[("name", name)],
))
.build();
dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("join", &gettext("Sync Here"));
dialog.set_response_appearance("join", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
let ui = ui.clone();
let uid = uid.to_string();
// Translators: {name} is the name of the other computer this one now syncs with.
let done = gettext_f("Now syncing with “{name}”", &[("name", name)]);
dialog.connect_response(None, move |_, resp| {
if resp != "join" {
return;
}
ui.busy_begin();
let rx = spawn_request(
ui.dirs.control_socket(),
Request::AdoptDevice {
uid: Some(uid.clone()),
},
);
let (ui, done) = (ui.clone(), done.clone());
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.busy_end();
let failed = gettext("Couldn't sync with that computer");
match result {
Ok(Ok(Response::Ok { .. })) => {
reload_sync_pages(&ui);
toast(&ui, &done);
// The device is this computer's now, so its own restore
// picker offers the folders.
prompt_restore_folders(&ui, None);
}
Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, &failed, &message, kind)
}
_ => toast_error(
&ui,
&failed,
&gettext("The Proton Drive service didn't respond."),
),
}
});
});
dialog.present(win.as_ref());
}
/// Ask before moving another computer's folders into this computer's backup,
/// then offer them for syncing here.
///
/// Destructive-looking on purpose: the other computer keeps its device but
/// loses its folders, and a copy of Proton Drive still running there would
/// watch them vanish.
pub(crate) fn prompt_migrate_device(ui: &Rc<Ui>, uid: &str, name: &str) {
let win = ui_window(ui);
let dialog = adw::AlertDialog::builder()
.heading(gettext("Move Backup to This Computer?"))
// Translators: {name} is the name of the other computer whose folders move to this one.
.body(gettext_f(
"The folders backed up by “{name}” move into this computer's backup in Proton Drive, and you choose where they go on this computer. “{name}” stays in your account, without those folders.\n\nStop Proton Drive on “{name}” first. Otherwise it sees its folders disappear.",
&[("name", name)],
))
.build();
dialog.add_response("cancel", &gettext("Cancel"));
dialog.add_response("move", &gettext("Move Here"));
dialog.set_response_appearance("move", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
let ui = ui.clone();
let uid = uid.to_string();
// Translators: {name} is the name of the computer whose folders moved to this one.
let done = gettext_f("Moved the backup of “{name}”", &[("name", name)]);
dialog.connect_response(None, move |_, resp| {
if resp != "move" {
return;
}
ui.busy_begin();
let rx = spawn_request(
ui.dirs.control_socket(),
Request::MigrateDevice { uid: uid.clone() },
);
let (ui, done) = (ui.clone(), done.clone());
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.busy_end();
let failed = gettext("Couldn't move that backup");
match result {
Ok(Ok(Response::Ok { .. })) => {
reload_sync_pages(&ui);
toast(&ui, &done);
// The folders are this computer's now, so its own restore
// picker is where the user chooses their local paths.
prompt_restore_folders(&ui, None);
}
Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, &failed, &message, kind)
}
_ => toast_error(
&ui,
&failed,
&gettext("The Proton Drive service didn't respond."),
),
}
});
});
dialog.present(win.as_ref());
}
/// Run a mutation raised from Computers or Locations and reload the page on success.
pub(crate) fn run_devices_mutation(ui: &Rc<Ui>, req: Request, done: &str, failed: &str) {
ui.busy_begin();
let rx = spawn_request(ui.dirs.control_socket(), req);
let ui = ui.clone();
let done = done.to_string();
let failed = failed.to_string();
glib::spawn_future_local(async move {
let result = rx.recv().await;
ui.busy_end();
match result {
Ok(Ok(Response::Ok { .. })) => {
reload_sync_pages(&ui);
toast(&ui, &done);
}
Ok(Ok(Response::Error { message, kind })) => {
toast_failure(&ui, &failed, &message, kind)
}
_ => toast_error(
&ui,
&failed,
&gettext("The Proton Drive service didn't respond."),
),
}
});
}
#[cfg(test)]
mod tests {
use super::*;
fn folder(state: &str) -> SyncFolderInfo {
SyncFolderInfo {
id: 1,
local_path: "/home/u/Documents".into(),
remote_uid: "vol~link".into(),
mode: "mirror".into(),
state: state.into(),
last_sync: 1_700_000_000,
pending_mode: None,
progress: None,
paused: false,
}
}
#[test]
fn a_machine_with_no_folders_says_so() {
assert_eq!(this_computer_subtitle(&[]), "No folders backed up yet");
}
/// A folder added a moment ago is not backed up yet, and the row must not
/// say it is (`docs/BUGS.md` B261). An on-demand folder has no first sync.
#[test]
fn a_folder_before_its_first_sync_is_not_counted_as_backed_up() {
let new = SyncFolderInfo {
last_sync: 0,
..folder("idle")
};
assert_eq!(
this_computer_subtitle(&[folder("idle"), new.clone()]),
"2 folders backed up · 1 waiting for its first sync"
);
let failed = SyncFolderInfo {
state: "error".into(),
..new.clone()
};
assert_eq!(
this_computer_subtitle(&[new.clone(), failed]),
"2 folders backed up · 1 waiting for its first sync · 1 needs attention"
);
let on_demand = SyncFolderInfo {
mode: "ondemand".into(),
..new
};
assert_eq!(this_computer_subtitle(&[on_demand]), "1 folder backed up");
}
#[test]
fn folders_needing_attention_are_counted_not_hidden() {
// The per-folder rows live on the Sync page, so this row is the only
// place the Computers page can surface that something is wrong.
assert_eq!(
this_computer_subtitle(&[folder("idle"), folder("idle")]),
"2 folders backed up"
);
assert_eq!(
this_computer_subtitle(&[folder("idle"), folder("error")]),
"2 folders backed up · 1 needs attention"
);
assert_eq!(
this_computer_subtitle(&[folder("conflict"), folder("error")]),
"2 folders backed up · 2 need attention"
);
}
fn scanning(done: usize, total: usize) -> SyncProgress {
SyncProgress {
phase: SyncPhase::Scanning,
done,
total,
current: String::new(),
}
}
#[test]
fn a_first_scan_counts_what_it_checked() {
// A first sync of a large folder scans for many minutes before anything
// downloads; without a count it reads as stuck (#46).
assert_eq!(
sync_progress_label(&scanning(0, 0)),
"checking for changes…"
);
assert_eq!(
sync_progress_label(&scanning(1, 0)),
"checking for changes — 1 item checked"
);
assert_eq!(
sync_progress_label(&scanning(1234, 0)),
"checking for changes — 1234 items checked"
);
assert_eq!(
sync_progress_label(&scanning(12, 40)),
"checking for changes — 12 of 40"
);
}
#[test]
fn the_restore_picker_names_the_computer_it_restores_from() {
assert!(restore_picker_body(Some("laptop")).contains("“laptop”"));
assert!(restore_picker_body(None).contains("this computer"));
assert!(nothing_to_restore(Some("laptop")).contains("“laptop”"));
}
fn device(device_type: &str, last_sync: Option<i64>) -> DeviceInfo {
DeviceInfo {
uid: "dev".into(),
name: "laptop".into(),
device_type: device_type.into(),
last_sync,
this_device: false,
adopted: false,
}
}
#[test]
fn a_computer_that_never_backed_up_says_no_backups_yet() {
assert_eq!(
device_subtitle(&device("Linux", None)),
"Linux · no backups yet"
);
assert_eq!(
device_subtitle(&device("Linux", Some(0))),
"Linux · no backups yet"
);
assert!(device_subtitle(&device("MacOs", Some(1))).starts_with("macOS · last backup "));
}
}