Merge remote-tracking branch 'origin/main' into pr32-merge

# Conflicts:
#	docs/CHANGELOG.md
#	po/de.po
#	po/en_GB.po
#	po/es.po
#	po/fr.po
#	po/it.po
#	po/nl.po
#	po/pdfs.pot
#	po/pl.po
#	po/pt_BR.po
This commit is contained in:
nvrl committed 2026-10-09 03:03:31 +02:00
commit 7fd3439b60
55 files changed
+6213 -4610

No files matched your search

@@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<!-- Main menu: three bars. Bundled because themes such as Papirus draw
open-menu-symbolic as three dots, the same as every row's "more" menu. -->
<path fill="#222222" d="M 2,2.5 H 14 A 1,1 0 0 1 14,4.5 H 2 A 1,1 0 0 1 2,2.5 Z M 2,7 H 14 A 1,1 0 0 1 14,9 H 2 A 1,1 0 0 1 2,7 Z M 2,11.5 H 14 A 1,1 0 0 1 14,13.5 H 2 A 1,1 0 0 1 2,11.5 Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 441 B

@@ -14,7 +14,6 @@
<file preprocess="xml-stripblanks">scalable/actions/pdfs-details-pane-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-link-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-map-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-menu-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-people-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-share-symbolic.svg</file>
<file preprocess="xml-stripblanks">scalable/actions/pdfs-sync-symbolic.svg</file>
+6 -1
View File
@@ -18,7 +18,9 @@
.sidebar-status image.warning { color: var(--warning-color); }
.sidebar-status image.error { color: var(--error-color); }
.sidebar-quota progressbar trough, .sidebar-quota progressbar progress { min-height: 4px; }
.sidebar-account { padding: 4px 4px 4px 8px; }
/* The account row is the main menu's button: a row, not a pushbutton, so
* the name keeps the text weight. */
button.sidebar-account { padding: 4px 8px; font-weight: normal; }
/* A count of what waits on a page, at the end of its sidebar row */
.sidebar-badge {
min-width: 10px;
@@ -48,6 +50,9 @@
.badge-cloud { color: color-mix(in srgb, currentColor 55%, transparent); }
.badge-issue { color: var(--error-color); }
.browser-statusbar { background-color: alpha(currentColor, 0.025); }
/* The folder on screen, last in the breadcrumb trail: a flat button's side
* padding, so the "›" before it is spaced like the ones between buttons. */
.crumb-current { padding: 0 10px; }
scale.browser-status-meter trough { min-width: 104px; min-height: 6px; }
.bulk-bar { padding: 6px 8px; border-radius: 12px; background: color-mix(in srgb, var(--accent-bg-color) 12%, transparent); }
.dropzone { border: 2px dashed alpha(currentColor, 0.25); border-radius: 16px; padding: 28px 18px; transition: background 160ms ease, border-color 160ms ease; }
+2 -20
View File
@@ -34,6 +34,7 @@ use pages::verify::*;
use reload::{LoadTicket, Loader, replace_items};
use theme::{load_resources, proton_theme_active, set_proton_theme};
use widgets::compat::*;
use widgets::crumb::*;
use widgets::details::*;
use widgets::file_list::*;
use widgets::menu::*;
@@ -830,7 +831,6 @@ fn build_sidebar(footer: &gtk4::Box) -> (adw::NavigationPage, gtk4::ListBox) {
let header = adw::HeaderBar::new();
header.set_title_widget(Some(&brand));
header.pack_end(&build_primary_menu());
let scroll = gtk4::ScrolledWindow::builder()
.hscrollbar_policy(gtk4::PolicyType::Never)
@@ -1153,25 +1153,7 @@ fn toast_failure(ui: &Rc<Ui>, what: &str, message: &str, kind: ErrorKind) {
}
}
/// The sidebar's primary (hamburger) menu: the app-level entries that don't
/// belong on any one page.
fn build_primary_menu() -> gtk4::MenuButton {
let menu = gio::Menu::new();
menu.append(Some(&gettext("Preferences")), Some("win.preferences"));
menu.append(Some(&gettext("Keyboard Shortcuts")), Some("win.shortcuts"));
menu.append(
Some(&gettext("About Proton Drive for Linux")),
Some("win.about"),
);
gtk4::MenuButton::builder()
.icon_name("pdfs-menu-symbolic")
.tooltip_text(gettext("Main menu"))
.primary(true)
.menu_model(&menu)
.build()
}
/// Back the primary and account menus' entries with window actions.
/// Back the main menu's entries with window actions.
fn install_window_actions(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
let preferences = gio::SimpleAction::new("preferences", None);
let ui_prefs = ui.clone();
+2 -9
View File
@@ -1220,9 +1220,7 @@ pub(crate) fn repaint_crumb(ui: &Rc<Ui>, path: &str) {
));
let mut acc = String::new();
for (i, seg) in segments.iter().enumerate() {
let sep = gtk4::Label::new(Some("›"));
sep.add_css_class("dim-label");
ui.browser.crumb.append(&sep);
ui.browser.crumb.append(&crumb_separator());
acc = if acc.is_empty() {
seg.to_string()
} else {
@@ -1237,12 +1235,7 @@ pub(crate) fn repaint_crumb(ui: &Rc<Ui>, path: &str) {
/// flat button that navigates to `target` (clearing any active search first).
pub(crate) fn crumb_node(ui: &Rc<Ui>, label: &str, target: &str, current: bool) -> gtk4::Widget {
if current {
let l = gtk4::Label::builder()
.label(label)
.ellipsize(gtk4::pango::EllipsizeMode::Start)
.build();
l.add_css_class("heading");
return l.upcast();
return crumb_current(label);
}
let button = gtk4::Button::builder().label(label).build();
button.add_css_class("flat");
+67 -9
View File
@@ -439,6 +439,10 @@ fn other_computer_row(ui: &Rc<Ui>, dev: &DeviceInfo) -> adw::ActionRow {
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("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 || {
@@ -483,9 +487,7 @@ fn repaint_device_crumb(ui: &Rc<Ui>, nav: &[(String, String)]) {
nav.is_empty(),
));
for (i, (_, name)) in nav.iter().enumerate() {
let sep = gtk4::Label::new(Some("›"));
sep.add_css_class("dim-label");
crumb.append(&sep);
crumb.append(&crumb_separator());
crumb.append(&device_crumb_node(ui, name, i + 1, i + 1 == nav.len()));
}
}
@@ -494,12 +496,7 @@ fn repaint_device_crumb(ui: &Rc<Ui>, nav: &[(String, String)]) {
/// 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 {
let l = gtk4::Label::builder()
.label(label)
.ellipsize(gtk4::pango::EllipsizeMode::Start)
.build();
l.add_css_class("heading");
return l.upcast();
return crumb_current(label);
}
let button = gtk4::Button::builder().label(label).build();
button.add_css_class("flat");
@@ -1096,6 +1093,67 @@ pub(crate) fn prompt_adopt_device(ui: &Rc<Ui>, uid: &str, name: &str) {
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();
+2 -9
View File
@@ -484,9 +484,7 @@ fn repaint_shared_crumb(ui: &Rc<Ui>, nav: &[(String, String)]) {
nav.is_empty(),
));
for (i, (_, name)) in nav.iter().enumerate() {
let sep = gtk4::Label::new(Some("›"));
sep.add_css_class("dim-label");
crumb.append(&sep);
crumb.append(&crumb_separator());
crumb.append(&shared_crumb_node(ui, name, i + 1, i + 1 == nav.len()));
}
}
@@ -495,12 +493,7 @@ fn repaint_shared_crumb(ui: &Rc<Ui>, nav: &[(String, String)]) {
/// that goes back up to `depth` folders below the top level.
fn shared_crumb_node(ui: &Rc<Ui>, label: &str, depth: usize, current: bool) -> gtk4::Widget {
if current {
let l = gtk4::Label::builder()
.label(label)
.ellipsize(gtk4::pango::EllipsizeMode::Start)
.build();
l.add_css_class("heading");
return l.upcast();
return crumb_current(label);
}
let button = gtk4::Button::builder().label(label).build();
button.add_css_class("flat");
@@ -565,6 +565,7 @@ pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entries: &[DirEntry]) {
let uids: Vec<String> = entries.iter().map(|e| e.uid.clone()).collect();
dialog.connect_response(Some("stop"), move |_, _| {
let done = done.clone();
let stopped = uids.clone();
run_requests(
&ui_c,
uids.iter()
@@ -572,6 +573,7 @@ pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entries: &[DirEntry]) {
.collect(),
gettext_noop("Couldn't stop sharing"),
move |ui| {
forget_shared_by_me(ui, &stopped);
reload_listing(ui);
toast(ui, &done);
},
@@ -580,6 +582,18 @@ pub(crate) fn prompt_stop_sharing(ui: &Rc<Ui>, entries: &[DirEntry]) {
dialog.present(ui_window(ui).as_ref());
}
/// Take items whose sharing just stopped off the page now, rather than when
/// the reload that confirms it comes back: that reload asks after every share
/// in turn and takes a while with many of them.
fn forget_shared_by_me(ui: &Rc<Ui>, uids: &[String]) {
let items = {
let mut items = ui.shared_by_me.items.borrow_mut();
items.retain(|item| !uids.contains(&item.uid));
items.clone()
};
repaint_shared_by_me(ui, &items);
}
/// Open a shared item: a folder in My Files, a file the way My Files would.
fn open_shared_by_me(ui: &Rc<Ui>, entry: &DirEntry) {
if entry.is_dir {
+24 -13
View File
@@ -381,23 +381,34 @@ pub(crate) fn build_main_page() -> MainWidgets {
.hexpand(true)
.ellipsize(gtk4::pango::EllipsizeMode::Middle)
.build();
// The app's one menu, on the account row the way Proton's own apps keep it:
// app-wide entries first, the account's last. The whole row opens it, and
// F10 too, as the primary menu.
let app_section = gio::Menu::new();
app_section.append(Some(&gettext("Preferences")), Some("win.preferences"));
app_section.append(Some(&gettext("Keyboard Shortcuts")), Some("win.shortcuts"));
app_section.append(
Some(&gettext("About Proton Drive for Linux")),
Some("win.about"),
);
let account_section = gio::Menu::new();
account_section.append(Some(&gettext("Sign Out…")), Some("win.sign-out"));
let account_menu = gio::Menu::new();
account_menu.append(Some(&gettext("Preferences")), Some("win.preferences"));
let sign_out = gio::Menu::new();
sign_out.append(Some(&gettext("Sign Out…")), Some("win.sign-out"));
account_menu.append_section(None, &sign_out);
let account_button = gtk4::MenuButton::builder()
.icon_name("view-more-symbolic")
.tooltip_text(gettext("Account"))
account_menu.append_section(None, &app_section);
account_menu.append_section(None, &account_section);
let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 10);
row.append(&avatar);
row.append(&account_name);
row.append(&gtk4::Image::from_icon_name("view-more-symbolic"));
let account = gtk4::MenuButton::builder()
.child(&row)
.tooltip_text(gettext("Main menu"))
.menu_model(&account_menu)
.valign(gtk4::Align::Center)
.direction(gtk4::ArrowType::Up)
.primary(true)
.build();
account_button.add_css_class("flat");
let account = gtk4::Box::new(gtk4::Orientation::Horizontal, 10);
account.add_css_class("flat");
account.add_css_class("sidebar-account");
account.append(&avatar);
account.append(&account_name);
account.append(&account_button);
let footer = gtk4::Box::new(gtk4::Orientation::Vertical, 10);
footer.add_css_class("sidebar-footer");
+21
View File
@@ -0,0 +1,21 @@
use crate::*;
/// The "›" between two breadcrumb segments.
pub(crate) fn crumb_separator() -> gtk4::Label {
let sep = gtk4::Label::new(Some("›"));
sep.add_css_class("dim-label");
sep
}
/// The last breadcrumb segment, the folder on screen: a heading rather than a
/// button. It carries a flat button's side padding (`.crumb-current`), so the
/// "›" before it sits as far from it as from the button before.
pub(crate) fn crumb_current(label: &str) -> gtk4::Widget {
let l = gtk4::Label::builder()
.label(label)
.ellipsize(gtk4::pango::EllipsizeMode::Start)
.build();
l.add_css_class("heading");
l.add_css_class("crumb-current");
l.upcast()
}
+6 -1
View File
@@ -132,8 +132,13 @@ impl FileList {
// and the grid collapses to a single column. Allowing a mid-word
// break is what keeps the two-line-then-ellipsis budget below
// enforceable for *every* name rather than only the ones that
// happen to have spaces.
// happen to have spaces. Pango marks such a break with a hyphen
// that is not in the name ("SteamSetup.e-xe"); a file name is
// not prose, so break it bare, as Nautilus does.
let no_hyphens = gtk4::pango::AttrList::new();
no_hyphens.insert(gtk4::pango::AttrInt::new_insert_hyphens(false));
let label = gtk4::Label::builder()
.attributes(&no_hyphens)
.ellipsize(gtk4::pango::EllipsizeMode::End)
.justify(gtk4::Justification::Center)
.max_width_chars(13)
+23 -9
View File
@@ -128,20 +128,34 @@ impl ActionMenu {
}
}
/// Where a menu opened over `anchor` should live in the widget tree: the
/// outermost grid or list view around it, or `anchor` itself. CSS descendant
/// selectors reach through a popover to its parent, and themes style the
/// insides of list items (`gridview > child box { margin: 12px }` in
/// Catppuccin); a menu parented to a tile picks those rules up and puts a wide
/// margin around every section. Under the view itself it is no item's child.
/// Where a menu opened over `anchor` should live in the widget tree.
///
/// Not inside a list item: CSS descendant selectors reach through a popover to
/// its parent, and themes style the insides of list items (`gridview > child
/// box { margin: 12px }` in Catppuccin); a menu parented to a tile picks those
/// rules up and puts a wide margin around every section. So the search starts
/// at the outermost grid or list view around `anchor`.
///
/// Not on a widget without a layout manager either: a popover is sized and
/// placed again when its parent's layout manager allocates it. The list views
/// and `ScrolledWindow` have none, so a menu parented there kept the size it was
/// first given, often too small, and opened scrolling. The host is the first
/// widget from there up that has one.
fn popover_host(anchor: &gtk4::Widget) -> gtk4::Widget {
let mut host = anchor.clone();
let mut start = anchor.clone();
let mut widget = anchor.parent();
while let Some(w) = widget {
widget = w.parent();
if w.is::<gtk4::GridView>() || w.is::<gtk4::ListView>() || w.is::<gtk4::ColumnView>() {
host = w;
start = w;
}
}
host
let mut host = Some(start.clone());
while let Some(w) = host {
if w.layout_manager().is_some() {
return w;
}
host = w.parent();
}
start
}
+1
View File
@@ -1,4 +1,5 @@
pub(crate) mod compat;
pub(crate) mod crumb;
pub(crate) mod details;
pub(crate) mod file_list;
pub(crate) mod menu;
+71 -51
View File
@@ -127,6 +127,26 @@ impl Drop for Busy {
}
}
/// A section's action, such as Send Invitations. It goes under the section's
/// rows, at their right edge, rather than in a row of its own: a row in a boxed
/// list reads as a setting, and a button alone in one looks misplaced.
fn group_button(label: &str) -> gtk4::Button {
let button = gtk4::Button::builder()
.label(label)
.halign(gtk4::Align::End)
.margin_top(12)
.build();
button.add_css_class("suggested-action");
button
}
/// A section's only row while the share is being read.
fn loading_row() -> adw::ActionRow {
let row = adw::ActionRow::builder().title(gettext("Loading…")).build();
row.add_prefix(&spinner());
row
}
/// How an open Share dialog addresses its node.
///
/// A node the browser is showing has a mountpoint-relative path. A node reached
@@ -287,26 +307,17 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
.selected(0)
.valign(gtk4::Align::Center)
.build();
let role_wrap = adw::ActionRow::builder().title(gettext("Role")).build();
role_wrap.add_suffix(&role_drop);
// The role goes with the addresses it applies to, at the end of their row.
role_drop.set_tooltip_text(Some(&gettext("Role")));
email_row.add_suffix(&role_drop);
let message_row = adw::EntryRow::builder()
.title(gettext("Message (optional)"))
.build();
let invite_btn = gtk4::Button::builder()
.label(gettext("Send Invitations"))
.halign(gtk4::Align::End)
.margin_top(6)
.build();
invite_btn.add_css_class("suggested-action");
let invite_btn = group_button(&gettext("Send Invitations"));
invite_btn.set_sensitive(false);
let invite_wrap = adw::PreferencesRow::builder()
.activatable(false)
.child(&invite_btn)
.build();
invite_group.add(&email_row);
invite_group.add(&role_wrap);
invite_group.add(&message_row);
invite_group.add(&invite_wrap);
invite_group.add(&invite_btn);
let people = adw::PreferencesGroup::builder()
.title(gettext("People with access"))
@@ -315,21 +326,18 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
.title(gettext("Public link"))
.build();
let content = gtk4::Box::new(gtk4::Orientation::Vertical, 18);
content.set_margin_top(12);
content.set_margin_bottom(12);
content.set_margin_start(12);
content.set_margin_end(12);
content.append(&invite_group);
content.append(&people);
content.append(&link_group);
let clamp = adw::Clamp::builder().child(&content).build();
let scroll = gtk4::ScrolledWindow::builder()
.vexpand(true)
.hscrollbar_policy(gtk4::PolicyType::Never)
.child(&clamp)
.build();
toolbar.set_content(Some(&scroll));
// Until the share is read, each section says so rather than standing
// empty under its title.
let people_loading = loading_row();
people.add(&people_loading);
let link_loading = loading_row();
link_group.add(&link_loading);
let content = adw::PreferencesPage::new();
content.add(&invite_group);
content.add(&people);
content.add(&link_group);
toolbar.set_content(Some(&content));
let dialog = adw::Dialog::builder()
.title(pgettext("verb", "Share"))
@@ -343,8 +351,8 @@ pub(crate) fn open_share_dialog(ui: &Rc<Ui>, entry: &DirEntry) {
target,
people,
link_group,
people_rows: RefCell::new(Vec::new()),
link_rows: RefCell::new(Vec::new()),
people_rows: RefCell::new(vec![people_loading.upcast()]),
link_rows: RefCell::new(vec![link_loading.upcast()]),
});
// Check the addresses as they are typed: a malformed one marks the row as
@@ -430,17 +438,35 @@ pub(crate) fn share_dialog_reload(state: &Rc<ShareDialog>) {
repaint_share_link(&state, link.as_ref());
}
Ok(Ok(Response::Error { message, .. })) => {
share_dialog_unread(&state);
toast_error(&state.ui, &gettext("Couldn't load sharing"), &message)
}
_ => toast_error(
&state.ui,
&gettext("Couldn't load sharing"),
&gettext("The Proton Drive service didn't respond."),
),
_ => {
share_dialog_unread(&state);
toast_error(
&state.ui,
&gettext("Couldn't load sharing"),
&gettext("The Proton Drive service didn't respond."),
)
}
}
});
}
/// The share couldn't be read: what the sections showed (rows still loading,
/// or ones that may now be stale) gives way to one line that says so.
fn share_dialog_unread(state: &Rc<ShareDialog>) {
for row in state.people_rows.borrow_mut().drain(..) {
state.people.remove(&row);
}
for row in state.link_rows.borrow_mut().drain(..) {
state.link_group.remove(&row);
}
let row = dim_row(&gettext("Couldn't load sharing"));
state.people.add(&row);
state.people_rows.borrow_mut().push(row.upcast());
}
/// Rebuild the "People with access" rows from a fresh share listing.
pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntry]) {
for row in state.people_rows.borrow_mut().drain(..) {
@@ -455,15 +481,18 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
return;
}
for entry in entries {
// A member is the plain case and needs no word; an invitation says
// it is still waiting on the other side.
let subtitle = match entry.kind {
ShareEntryKind::Member => gettext("Member"),
ShareEntryKind::ProtonInvite => gettext("Invited (pending)"),
ShareEntryKind::ExternalInvite => gettext("Invited (external, pending)"),
ShareEntryKind::Member => String::new(),
ShareEntryKind::ProtonInvite => gettext("Invitation pending"),
ShareEntryKind::ExternalInvite => gettext("Invitation pending, no Proton account yet"),
};
let row = adw::ActionRow::builder()
.title(&entry.email)
.subtitle(&subtitle)
.build();
row.add_prefix(&adw::Avatar::new(32, Some(&entry.email), true));
// External invites can't have their role changed; show it read-only.
// Members and Proton invites get a role dropdown.
@@ -653,16 +682,7 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
.build();
let expiry_row = adw::ActionRow::builder().title(gettext("Expires")).build();
expiry_row.add_suffix(&expiry_drop);
let create = gtk4::Button::builder()
.label(gettext("Create Public Link"))
.halign(gtk4::Align::End)
.margin_top(6)
.build();
create.add_css_class("suggested-action");
let create_wrap = adw::PreferencesRow::builder()
.activatable(false)
.child(&create)
.build();
let create = group_button(&gettext("Create Public Link"));
// Enter in the password field creates the link.
let btn = create.clone();
@@ -689,11 +709,11 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
state.link_group.add(&role_row);
state.link_group.add(&pw_row);
state.link_group.add(&expiry_row);
state.link_group.add(&create_wrap);
state.link_group.add(&create);
rows.push(role_row.upcast());
rows.push(pw_row.upcast());
rows.push(expiry_row.upcast());
rows.push(create_wrap.upcast());
rows.push(create.upcast());
}
}
*state.link_rows.borrow_mut() = rows;
+60 -17
View File
@@ -88,8 +88,9 @@ struct DriveState {
/// Summarise a work snapshot into one menu line, or empty when idle. A single
/// transfer names the file and its percentage; several collapse to counts so the
/// menu stays one line regardless of queue depth. With nothing moving bytes, a
/// running job speaks for itself — a scan or an index rebuild is still "busy",
/// and the tray saying nothing there reads as "finished".
/// running job speaks for itself — a scan or an index rebuild is still work
/// going on, and the menu saying nothing there reads as "finished". The icon is
/// [`busy`]'s call, not this line's.
fn sync_line(items: &[TransferItem], jobs: &[JobItem]) -> String {
match items {
[] => match jobs.first() {
@@ -143,12 +144,12 @@ struct DriveTray {
fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
match send(socket, &Request::Status) {
Ok(status @ Response::Status { .. }) => {
// Same daemon is up, so a cheap follow-up request gives the sync line.
let sync = match send(socket, &Request::GetQueueStatus) {
Ok(Response::Transfers { items, jobs }) => sync_line(&items, &jobs),
_ => String::new(),
// Same daemon is up, so a cheap follow-up request says what moves.
let (items, jobs) = match send(socket, &Request::GetQueueStatus) {
Ok(Response::Transfers { items, jobs }) => (items, jobs),
_ => (Vec::new(), Vec::new()),
};
status_state(&status, sync, default_mountpoint)
status_state(&status, &items, &jobs, default_mountpoint)
}
// Socket answered but with something unexpected — treat as up but odd.
Ok(_) => unexpected_reply(default_mountpoint),
@@ -217,9 +218,14 @@ fn unexpected_reply(default_mountpoint: &Path) -> DriveState {
}
}
/// The state a running daemon's [`Response::Status`] describes, with `sync`
/// saying what it is moving right now.
fn status_state(status: &Response, sync: String, default_mountpoint: &Path) -> DriveState {
/// The state a running daemon's [`Response::Status`] describes, with `items`
/// and `jobs` saying what it is working on right now.
fn status_state(
status: &Response,
items: &[TransferItem],
jobs: &[JobItem],
default_mountpoint: &Path,
) -> DriveState {
let Response::Status {
mountpoint,
pinned,
@@ -239,7 +245,7 @@ fn status_state(status: &Response, sync: String, default_mountpoint: &Path) -> D
paused,
failing_ops,
online,
queued.is_some() || !sync.is_empty(),
queued.is_some() || busy(items, jobs),
);
DriveState {
line: match (online, queued) {
@@ -278,11 +284,19 @@ fn status_state(status: &Response, sync: String, default_mountpoint: &Path) -> D
mounted: true,
paused,
mountpoint: PathBuf::from(mountpoint),
sync,
sync: sync_line(items, jobs),
failing: failing_ops,
}
}
/// Whether the daemon is changing something: bytes moving, or a job the user
/// is waiting on. A mirror folder's routine scan and the like are background
/// jobs; they still get the menu line, but the icon spinning every two minutes
/// for a scan that finds nothing reads as constant syncing.
fn busy(items: &[TransferItem], jobs: &[JobItem]) -> bool {
!items.is_empty() || jobs.iter().any(|j| !j.background)
}
/// The phase a running mount is in. A pause is the user's own doing and says
/// so first; failures outrank everything else the daemon reports.
fn phase_of(paused: bool, failing: u64, online: bool, busy: bool) -> Phase {
@@ -547,11 +561,7 @@ fn main() -> Result<()> {
heard.take(feed);
}
let st = match &heard.status {
Some(status) => status_state(
status,
sync_line(&heard.items, &heard.jobs),
&default_mountpoint,
),
Some(status) => status_state(status, &heard.items, &heard.jobs, &default_mountpoint),
// Lost the daemon: ask once, which describes the sign-in instead.
None => poll_state(&socket, &default_mountpoint),
};
@@ -596,6 +606,39 @@ mod tests {
assert_eq!(phase_of(false, 0, true, false), Phase::Synced);
}
fn job(title: &str, background: bool) -> JobItem {
JobItem {
title: title.to_string(),
detail: String::new(),
done: 0,
total: 0,
background,
}
}
#[test]
fn a_routine_scan_is_not_busy_but_a_sync_is() {
assert!(!busy(&[], &[]));
assert!(!busy(&[], &[job("Checking Documents", true)]));
assert!(busy(
&[],
&[
job("Checking Documents", true),
job("Syncing Photos", false)
]
));
assert!(busy(&[], &[job("Uploading files", false)]));
}
#[test]
fn a_job_from_an_older_daemon_counts_as_busy() {
let job: JobItem = serde_json::from_str(
r#"{"title":"Checking Documents","detail":"","done":0,"total":0}"#,
)
.unwrap();
assert!(!job.background);
}
#[test]
fn the_issues_item_counts_in_the_singular_and_plural() {
assert_eq!(issues_label(1), "1 issue — View");