mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
fix(core,fuse,gui): UI/UX quick wins from the audit (phase 0)
- Move Google Takeout import out of Settings; Photos owns it, and the
Takeout page's back button returns there.
- Trash from the file browser is Undo-only (no confirm); F2 on a
multi-selection explains why it does nothing.
- Ask before sign-out, rejecting an invitation, removing someone's access,
removing a public link, and switching a folder to on-demand.
- Clearer offline wording ("Make available offline" / "Make online only")
with dedicated offline, online-only and upload symbolic icons; the pin row
is hidden for folders.
- Search hits report whether a file is cached, so search results show the
offline badge.
- Activity log: readable Empty Trash / link entries, distinct icons, and
error/warning styling for failures and conflicts.
- Proper singular/plural counts instead of "item(s)" / "archive(s)".
- Name earlier versions by save time instead of revision id; capitalise
the public link role; stop-syncing uses a stop icon, not a trash can.
- Label the Photos Upload and "Show on map" buttons; toast when a
thumbnail build finishes; the versions button follows the mount state.
- Mode switches and device mutations reload both sync pages.
This commit is contained in:
1 parent
690b91deab
commit
cc308ac25a
27 files changed
+341
-190
No files matched your search
@@ -976,6 +976,10 @@ pub struct SearchHit {
|
|||||||
pub size: u64,
|
pub size: u64,
|
||||||
pub modified: i64,
|
pub modified: i64,
|
||||||
pub pinned: bool,
|
pub pinned: bool,
|
||||||
|
/// Whether a valid copy of the content is in the local cache. Legacy
|
||||||
|
/// daemons omit it; clients then treat the file as online-only.
|
||||||
|
#[serde(default)]
|
||||||
|
pub cached: bool,
|
||||||
/// Node uid in `volume~link` form.
|
/// Node uid in `volume~link` form.
|
||||||
pub uid: String,
|
pub uid: String,
|
||||||
/// Absolute path through the most specific active on-demand mount, when the
|
/// Absolute path through the most specific active on-demand mount, when the
|
||||||
@@ -2133,6 +2137,7 @@ mod tests {
|
|||||||
size: 12,
|
size: 12,
|
||||||
modified: 123,
|
modified: 123,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
|
cached: false,
|
||||||
uid: "vol~link".into(),
|
uid: "vol~link".into(),
|
||||||
mounted_path: Some("/home/me/Videos/report.pdf".into()),
|
mounted_path: Some("/home/me/Videos/report.pdf".into()),
|
||||||
score: 985,
|
score: 985,
|
||||||
|
|||||||
@@ -810,9 +810,14 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
|||||||
},
|
},
|
||||||
Ok(CtlRequest::EmptyTrash) => match core.empty_trash() {
|
Ok(CtlRequest::EmptyTrash) => match core.empty_trash() {
|
||||||
Ok(n) => {
|
Ok(n) => {
|
||||||
core.log_activity(ActivityKind::EmptyTrash, format!("{n} item(s)"), "", true);
|
let items = if n == 1 {
|
||||||
|
"1 item".to_string()
|
||||||
|
} else {
|
||||||
|
format!("{n} items")
|
||||||
|
};
|
||||||
|
core.log_activity(ActivityKind::EmptyTrash, items.clone(), "", true);
|
||||||
CtlResponse::Ok {
|
CtlResponse::Ok {
|
||||||
message: format!("emptied trash ({n} item(s))"),
|
message: format!("emptied trash ({items})"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => CtlResponse::error(e),
|
Err(e) => CtlResponse::error(e),
|
||||||
|
|||||||
@@ -3865,6 +3865,12 @@ impl Core {
|
|||||||
size: node_size(&h.node),
|
size: node_size(&h.node),
|
||||||
modified: h.node.modification_time,
|
modified: h.node.modification_time,
|
||||||
pinned: pinned.contains(&h.node.uid.to_string()),
|
pinned: pinned.contains(&h.node.uid.to_string()),
|
||||||
|
cached: !h.node.is_folder()
|
||||||
|
&& self.cache.is_cached(
|
||||||
|
&h.node.uid,
|
||||||
|
h.node.modification_time,
|
||||||
|
node_size(&h.node),
|
||||||
|
),
|
||||||
uid: h.node.uid.to_string(),
|
uid: h.node.uid.to_string(),
|
||||||
mounted_path: roots.resolve(&h.path),
|
mounted_path: roots.resolve(&h.path),
|
||||||
path: h.path,
|
path: h.path,
|
||||||
@@ -3932,6 +3938,12 @@ impl Core {
|
|||||||
size: node_size(&hit.node),
|
size: node_size(&hit.node),
|
||||||
modified: hit.node.modification_time,
|
modified: hit.node.modification_time,
|
||||||
pinned: is_pinned,
|
pinned: is_pinned,
|
||||||
|
cached: !hit.node.is_folder()
|
||||||
|
&& self.cache.is_cached(
|
||||||
|
&hit.node.uid,
|
||||||
|
hit.node.modification_time,
|
||||||
|
node_size(&hit.node),
|
||||||
|
),
|
||||||
uid,
|
uid,
|
||||||
mounted_path: roots.resolve(&hit.path),
|
mounted_path: roots.resolve(&hit.path),
|
||||||
path: hit.path,
|
path: hit.path,
|
||||||
|
|||||||
@@ -22,10 +22,5 @@ fn main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
println!("cargo:rerun-if-changed=resources/pdfs.gresource.xml");
|
println!("cargo:rerun-if-changed=resources/pdfs.gresource.xml");
|
||||||
println!(
|
println!("cargo:rerun-if-changed=resources/icons");
|
||||||
"cargo:rerun-if-changed=resources/icons/scalable/actions/pdfs-build-thumbnails-symbolic.svg"
|
|
||||||
);
|
|
||||||
println!(
|
|
||||||
"cargo:rerun-if-changed=resources/icons/scalable/actions/pdfs-folder-upload-symbolic.svg"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- "Available offline": a filled disc with a down arrow knocked out of it
|
||||||
|
(one path, fill-rule evenodd, so symbolic recolouring keeps working).
|
||||||
|
Replaces the star, which every desktop uses for "favourite". -->
|
||||||
|
<path fill="#222222" fill-rule="evenodd" d="M 8,1 A 7,7 0 1 0 8,15 A 7,7 0 1 0 8,1 Z M 7,4 H 9 V 8.6 L 10.8,6.8 L 12.2,8.2 L 8,12.4 L 3.8,8.2 L 5.2,6.8 L 7,8.6 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 485 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- "Online only": a cloud outline. Lighter than the stock weather icon, so a
|
||||||
|
folder full of cloud-only files does not shout. -->
|
||||||
|
<path fill="#222222" fill-rule="evenodd" d="M 8.5,3 C 6.6,3 5,4.3 4.6,6.1 C 2.6,6.3 1,8 1,10 C 1,12.2 2.8,14 5,14 H 12 C 13.7,14 15,12.7 15,11 C 15,9.5 13.9,8.2 12.4,8 C 12.1,5.2 10.5,3 8.5,3 Z M 8.5,4.5 C 9.9,4.5 11,5.9 11,8 V 9.4 H 12 C 12.8,9.4 13.5,10.1 13.5,11 C 13.5,11.8 12.8,12.5 12,12.5 H 5 C 3.6,12.5 2.5,11.4 2.5,10 C 2.5,8.6 3.6,7.5 5,7.5 H 5.9 L 6,6.7 C 6.2,5.4 7.2,4.5 8.5,4.5 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 629 B |
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<!-- Upload-files action: an up arrow over a tray, the pair to
|
||||||
|
pdfs-folder-upload-symbolic. Stock themes only offer `document-send`,
|
||||||
|
which reads as "send by mail". -->
|
||||||
|
<path fill="#222222" d="M 8,1 L 3.5,5.5 L 4.9,6.9 L 7,4.8 V 11 H 9 V 4.8 L 11.1,6.9 L 12.5,5.5 Z"/>
|
||||||
|
<path fill="#222222" d="M 1,10 V 14 C 1,14.6 1.4,15 2,15 H 14 C 14.6,15 15,14.6 15,14 V 10 H 13 V 13 H 3 V 10 Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 494 B |
@@ -6,5 +6,8 @@
|
|||||||
<gresource prefix="/de/nils/protondrivelinux/icons">
|
<gresource prefix="/de/nils/protondrivelinux/icons">
|
||||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-build-thumbnails-symbolic.svg</file>
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-build-thumbnails-symbolic.svg</file>
|
||||||
<file preprocess="xml-stripblanks">scalable/actions/pdfs-folder-upload-symbolic.svg</file>
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-folder-upload-symbolic.svg</file>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-upload-symbolic.svg</file>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-offline-symbolic.svg</file>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/actions/pdfs-online-only-symbolic.svg</file>
|
||||||
</gresource>
|
</gresource>
|
||||||
</gresources>
|
</gresources>
|
||||||
@@ -603,7 +603,6 @@ fn build_window(app: &adw::Application) {
|
|||||||
&ui,
|
&ui,
|
||||||
&main_widgets.purge_button,
|
&main_widgets.purge_button,
|
||||||
&main_widgets.mountpoint_button,
|
&main_widgets.mountpoint_button,
|
||||||
&main_widgets.import_row,
|
|
||||||
);
|
);
|
||||||
wire_sidebar(&ui);
|
wire_sidebar(&ui);
|
||||||
wire_browser(&ui, &browser_widgets.grid, &browser_widgets.column_view);
|
wire_browser(&ui, &browser_widgets.grid, &browser_widgets.column_view);
|
||||||
@@ -773,12 +772,12 @@ fn build_sidebar() -> (adw::NavigationPage, gtk4::ListBox) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The sidebar destination a page belongs under. Most pages are their own
|
/// The sidebar destination a page belongs under. Most pages are their own
|
||||||
/// destination; a sub-page reached from one (Import, off Settings) has no row of
|
/// destination; a sub-page reached from one (Import, off Photos) has no row of
|
||||||
/// its own and answers with its parent, so the sidebar highlights where the user
|
/// its own and answers with its parent, so the sidebar highlights where the user
|
||||||
/// came from rather than clearing — which would read as "nowhere".
|
/// came from rather than clearing — which would read as "nowhere".
|
||||||
fn destination_of(page: &str) -> &str {
|
fn destination_of(page: &str) -> &str {
|
||||||
match page {
|
match page {
|
||||||
"takeout" => "main",
|
"takeout" => "gallery",
|
||||||
other => other,
|
other => other,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -956,6 +955,12 @@ fn sync_sidebar(ui: &Rc<Ui>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// "1 item" / "5 items": a count with the noun form that agrees with it, so
|
||||||
|
/// no user-facing string has to fall back to "item(s)".
|
||||||
|
fn count_noun(n: usize, one: &str, many: &str) -> String {
|
||||||
|
format!("{n} {}", if n == 1 { one } else { many })
|
||||||
|
}
|
||||||
|
|
||||||
/// Show a transient toast. Non-blocking by design: an action's outcome is
|
/// Show a transient toast. Non-blocking by design: an action's outcome is
|
||||||
/// reported without stealing focus or forcing a click, so the user can keep
|
/// reported without stealing focus or forcing a click, so the user can keep
|
||||||
/// working while a slow upload lands.
|
/// working while a slow upload lands.
|
||||||
@@ -1134,7 +1139,11 @@ fn install_shortcuts(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
|||||||
gtk4::gdk::Key::n | gtk4::gdk::Key::N if ctrl && on_browser => prompt_new_folder(&ui),
|
gtk4::gdk::Key::n | gtk4::gdk::Key::N if ctrl && on_browser => prompt_new_folder(&ui),
|
||||||
gtk4::gdk::Key::u | gtk4::gdk::Key::U if ctrl && on_browser => prompt_upload(&ui),
|
gtk4::gdk::Key::u | gtk4::gdk::Key::U if ctrl && on_browser => prompt_upload(&ui),
|
||||||
gtk4::gdk::Key::F2 if on_browser => {
|
gtk4::gdk::Key::F2 if on_browser => {
|
||||||
if let Some(entry) = selected_entry(&ui) {
|
// Renaming is one name at a time; saying so beats a key that
|
||||||
|
// silently does nothing with several items highlighted.
|
||||||
|
if selected_entries(&ui).len() > 1 {
|
||||||
|
toast(&ui, "Select a single item to rename it");
|
||||||
|
} else if let Some(entry) = selected_entry(&ui) {
|
||||||
prompt_rename(&ui, &entry);
|
prompt_rename(&ui, &entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1144,7 +1153,7 @@ fn install_shortcuts(ui: &Rc<Ui>, window: &adw::ApplicationWindow) {
|
|||||||
gtk4::gdk::Key::Delete if on_browser => {
|
gtk4::gdk::Key::Delete if on_browser => {
|
||||||
let entries = selected_entries(&ui);
|
let entries = selected_entries(&ui);
|
||||||
if !entries.is_empty() {
|
if !entries.is_empty() {
|
||||||
prompt_delete_many(&ui, entries);
|
trash_entries(&ui, entries);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
gtk4::gdk::Key::Escape
|
gtk4::gdk::Key::Escape
|
||||||
@@ -1196,6 +1205,28 @@ fn ui_window(ui: &Rc<Ui>) -> Option<gtk4::Window> {
|
|||||||
ui.stack.root().and_downcast::<gtk4::Window>()
|
ui.stack.root().and_downcast::<gtk4::Window>()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ask before an action that has no Undo. `on_confirm` runs only when the user
|
||||||
|
/// picks `action`; Cancel is the default, so Enter never destroys anything.
|
||||||
|
fn confirm_destructive(
|
||||||
|
parent: &impl IsA<gtk4::Widget>,
|
||||||
|
heading: &str,
|
||||||
|
body: &str,
|
||||||
|
action: &str,
|
||||||
|
on_confirm: impl Fn() + 'static,
|
||||||
|
) {
|
||||||
|
let dialog = adw::AlertDialog::builder()
|
||||||
|
.heading(heading)
|
||||||
|
.body(body)
|
||||||
|
.build();
|
||||||
|
dialog.add_response("cancel", "Cancel");
|
||||||
|
dialog.add_response("confirm", action);
|
||||||
|
dialog.set_response_appearance("confirm", adw::ResponseAppearance::Destructive);
|
||||||
|
dialog.set_default_response(Some("cancel"));
|
||||||
|
dialog.set_close_response("cancel");
|
||||||
|
dialog.connect_response(Some("confirm"), move |_, _| on_confirm());
|
||||||
|
dialog.present(Some(parent));
|
||||||
|
}
|
||||||
|
|
||||||
/// A dim, non-interactive placeholder row for an empty section.
|
/// A dim, non-interactive placeholder row for an empty section.
|
||||||
fn dim_row(text: &str) -> adw::ActionRow {
|
fn dim_row(text: &str) -> adw::ActionRow {
|
||||||
let row = adw::ActionRow::builder().title(text).build();
|
let row = adw::ActionRow::builder().title(text).build();
|
||||||
|
|||||||
@@ -248,16 +248,28 @@ pub(crate) fn repaint_activity(ui: &Rc<Ui>, items: &[ActivityEntry]) {
|
|||||||
if !a.detail.is_empty() {
|
if !a.detail.is_empty() {
|
||||||
subtitle = format!("{subtitle} · {}", a.detail);
|
subtitle = format!("{subtitle} · {}", a.detail);
|
||||||
}
|
}
|
||||||
|
let title = match a.kind {
|
||||||
|
// The target of an empty is a count, not a name to lead with a verb.
|
||||||
|
ActivityKind::EmptyTrash => format!("Emptied Trash ({})", a.target),
|
||||||
|
kind => format!("{} {}", activity_verb(kind), a.target),
|
||||||
|
};
|
||||||
let row = adw::ActionRow::builder()
|
let row = adw::ActionRow::builder()
|
||||||
.title(format!("{} {}", activity_verb(a.kind), a.target))
|
.title(title)
|
||||||
.subtitle(subtitle)
|
.subtitle(subtitle)
|
||||||
.build();
|
.build();
|
||||||
let icon = if a.ok {
|
// A failure and a conflict are different news: a conflict kept both
|
||||||
|
// copies, a failure lost the action. Each gets its own icon and colour.
|
||||||
|
let icon = gtk4::Image::from_icon_name(if a.ok {
|
||||||
activity_icon(a.kind)
|
activity_icon(a.kind)
|
||||||
} else {
|
} else {
|
||||||
"dialog-warning-symbolic"
|
"dialog-error-symbolic"
|
||||||
};
|
});
|
||||||
row.add_prefix(>k4::Image::from_icon_name(icon));
|
if !a.ok {
|
||||||
|
icon.add_css_class("error");
|
||||||
|
} else if a.kind == ActivityKind::Conflict {
|
||||||
|
icon.add_css_class("warning");
|
||||||
|
}
|
||||||
|
row.add_prefix(&icon);
|
||||||
row.set_activatable(false);
|
row.set_activatable(false);
|
||||||
ui.activity.group.add(&row);
|
ui.activity.group.add(&row);
|
||||||
rows.push(row.upcast());
|
rows.push(row.upcast());
|
||||||
@@ -277,9 +289,9 @@ pub(crate) fn activity_verb(kind: ActivityKind) -> &'static str {
|
|||||||
ActivityKind::Trash => "Trashed",
|
ActivityKind::Trash => "Trashed",
|
||||||
ActivityKind::Restore => "Restored",
|
ActivityKind::Restore => "Restored",
|
||||||
ActivityKind::DeleteForever => "Deleted",
|
ActivityKind::DeleteForever => "Deleted",
|
||||||
ActivityKind::EmptyTrash => "Emptied trash —",
|
ActivityKind::EmptyTrash => "Emptied Trash",
|
||||||
ActivityKind::Share => "Shared",
|
ActivityKind::Share => "Shared",
|
||||||
ActivityKind::PublicLink => "Linked",
|
ActivityKind::PublicLink => "Created a link to",
|
||||||
ActivityKind::Unshare => "Unshared",
|
ActivityKind::Unshare => "Unshared",
|
||||||
ActivityKind::Conflict => "Conflict",
|
ActivityKind::Conflict => "Conflict",
|
||||||
}
|
}
|
||||||
@@ -288,15 +300,14 @@ pub(crate) fn activity_verb(kind: ActivityKind) -> &'static str {
|
|||||||
/// A themed icon for an activity kind.
|
/// A themed icon for an activity kind.
|
||||||
pub(crate) fn activity_icon(kind: ActivityKind) -> &'static str {
|
pub(crate) fn activity_icon(kind: ActivityKind) -> &'static str {
|
||||||
match kind {
|
match kind {
|
||||||
ActivityKind::Upload => "document-send-symbolic",
|
ActivityKind::Upload => "pdfs-upload-symbolic",
|
||||||
ActivityKind::Download => "document-save-symbolic",
|
ActivityKind::Download => "document-save-symbolic",
|
||||||
ActivityKind::Sync => "emblem-synchronizing-symbolic",
|
ActivityKind::Sync => "emblem-synchronizing-symbolic",
|
||||||
ActivityKind::Rename => "document-edit-symbolic",
|
ActivityKind::Rename => "document-edit-symbolic",
|
||||||
ActivityKind::Move => "go-jump-symbolic",
|
ActivityKind::Move => "go-jump-symbolic",
|
||||||
ActivityKind::CreateFolder => "folder-new-symbolic",
|
ActivityKind::CreateFolder => "folder-new-symbolic",
|
||||||
ActivityKind::Trash | ActivityKind::DeleteForever | ActivityKind::EmptyTrash => {
|
ActivityKind::Trash => "user-trash-symbolic",
|
||||||
"user-trash-symbolic"
|
ActivityKind::DeleteForever | ActivityKind::EmptyTrash => "edit-delete-symbolic",
|
||||||
}
|
|
||||||
ActivityKind::Restore => "edit-undo-symbolic",
|
ActivityKind::Restore => "edit-undo-symbolic",
|
||||||
ActivityKind::Share | ActivityKind::PublicLink => "emblem-shared-symbolic",
|
ActivityKind::Share | ActivityKind::PublicLink => "emblem-shared-symbolic",
|
||||||
ActivityKind::Unshare => "action-unavailable-symbolic",
|
ActivityKind::Unshare => "action-unavailable-symbolic",
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
|
|||||||
.build();
|
.build();
|
||||||
new_folder.add_css_class("flat");
|
new_folder.add_css_class("flat");
|
||||||
let upload = gtk4::Button::builder()
|
let upload = gtk4::Button::builder()
|
||||||
.icon_name("document-send-symbolic")
|
.icon_name("pdfs-upload-symbolic")
|
||||||
.tooltip_text("Upload files")
|
.tooltip_text("Upload files")
|
||||||
.valign(gtk4::Align::Center)
|
.valign(gtk4::Align::Center)
|
||||||
.build();
|
.build();
|
||||||
@@ -337,12 +337,12 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
|
|||||||
// instead of making the whole view jump when a second entry is selected.
|
// instead of making the whole view jump when a second entry is selected.
|
||||||
let bulk_label = gtk4::Label::builder().hexpand(true).xalign(0.0).build();
|
let bulk_label = gtk4::Label::builder().hexpand(true).xalign(0.0).build();
|
||||||
let bulk_pin = gtk4::Button::builder()
|
let bulk_pin = gtk4::Button::builder()
|
||||||
.label("Keep offline")
|
.label("Make available offline")
|
||||||
.valign(gtk4::Align::Center)
|
.valign(gtk4::Align::Center)
|
||||||
.build();
|
.build();
|
||||||
bulk_pin.add_css_class("flat");
|
bulk_pin.add_css_class("flat");
|
||||||
let bulk_unpin = gtk4::Button::builder()
|
let bulk_unpin = gtk4::Button::builder()
|
||||||
.label("Remove offline copy")
|
.label("Make online only")
|
||||||
.valign(gtk4::Align::Center)
|
.valign(gtk4::Align::Center)
|
||||||
.build();
|
.build();
|
||||||
bulk_unpin.add_css_class("flat");
|
bulk_unpin.add_css_class("flat");
|
||||||
@@ -573,39 +573,15 @@ pub(crate) fn sync_bulk_bar(ui: &Rc<Ui>) {
|
|||||||
ui.browser.bulk.set_reveal_child(true);
|
ui.browser.bulk.set_reveal_child(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Confirm and move every selected entry to Trash.
|
/// Move every selected entry to Trash.
|
||||||
///
|
///
|
||||||
/// One dialog for the batch, one pass over the daemon, one toast — and one Undo
|
/// No confirmation: Trash is itself the safety net, and the toast carries one
|
||||||
/// that restores the whole batch, because a mis-aimed bulk delete is exactly the
|
/// Undo that restores the whole batch. Asking first *and* offering Undo after
|
||||||
/// action a user most needs to take back.
|
/// would put two hurdles in front of a reversible action.
|
||||||
pub(crate) fn prompt_delete_many(ui: &Rc<Ui>, entries: Vec<DirEntry>) {
|
pub(crate) fn trash_entries(ui: &Rc<Ui>, entries: Vec<DirEntry>) {
|
||||||
if entries.len() < 2 {
|
if !entries.is_empty() {
|
||||||
if let Some(entry) = entries.first() {
|
run_bulk_delete(ui, entries);
|
||||||
prompt_delete(ui, entry);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
let win = ui_window(ui);
|
|
||||||
let dialog = adw::AlertDialog::builder()
|
|
||||||
.heading("Move to Trash")
|
|
||||||
.body(format!(
|
|
||||||
"Move {} items to Trash? Anything inside a selected folder goes with it.",
|
|
||||||
entries.len()
|
|
||||||
))
|
|
||||||
.build();
|
|
||||||
dialog.add_response("cancel", "Cancel");
|
|
||||||
dialog.add_response("trash", "Move to Trash");
|
|
||||||
dialog.set_response_appearance("trash", 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 == "trash" {
|
|
||||||
run_bulk_delete(&ui, entries.clone());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
dialog.present(win.as_ref());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Trash a batch, one request at a time.
|
/// Trash a batch, one request at a time.
|
||||||
@@ -729,7 +705,7 @@ pub(crate) fn run_bulk_pin(ui: &Rc<Ui>, entries: Vec<DirEntry>, pin: bool) {
|
|||||||
&if pin {
|
&if pin {
|
||||||
format!("{n} files are now available offline")
|
format!("{n} files are now available offline")
|
||||||
} else {
|
} else {
|
||||||
format!("{n} files are no longer kept offline")
|
format!("{n} files are now online only")
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -746,7 +722,7 @@ pub(crate) fn wire_bulk(
|
|||||||
let ui_trash = ui.clone();
|
let ui_trash = ui.clone();
|
||||||
ui.browser
|
ui.browser
|
||||||
.bulk_trash
|
.bulk_trash
|
||||||
.connect_clicked(move |_| prompt_delete_many(&ui_trash, selected_entries(&ui_trash)));
|
.connect_clicked(move |_| trash_entries(&ui_trash, selected_entries(&ui_trash)));
|
||||||
let ui_pin = ui.clone();
|
let ui_pin = ui.clone();
|
||||||
ui.browser
|
ui.browser
|
||||||
.bulk_pin
|
.bulk_pin
|
||||||
@@ -1089,9 +1065,9 @@ pub(crate) fn show_context_menu(ui: &Rc<Ui>, entry: &DirEntry, anchor: >k4::Bo
|
|||||||
|
|
||||||
if !entry.is_dir {
|
if !entry.is_dir {
|
||||||
let (label, icon) = if entry.pinned {
|
let (label, icon) = if entry.pinned {
|
||||||
("Unpin", "non-starred-symbolic")
|
("Make online only", "pdfs-online-only-symbolic")
|
||||||
} else {
|
} else {
|
||||||
("Keep offline", "starred-symbolic")
|
("Make available offline", "pdfs-offline-symbolic")
|
||||||
};
|
};
|
||||||
let pin = menu_item(label, icon);
|
let pin = menu_item(label, icon);
|
||||||
let ui_pin = ui.clone();
|
let ui_pin = ui.clone();
|
||||||
@@ -1144,7 +1120,7 @@ pub(crate) fn show_context_menu(ui: &Rc<Ui>, entry: &DirEntry, anchor: >k4::Bo
|
|||||||
let pop = popover.clone();
|
let pop = popover.clone();
|
||||||
trash.connect_clicked(move |_| {
|
trash.connect_clicked(move |_| {
|
||||||
pop.popdown();
|
pop.popdown();
|
||||||
prompt_delete(&ui_tr, &entry_tr);
|
trash_entry(&ui_tr, &entry_tr);
|
||||||
});
|
});
|
||||||
menu.append(&trash);
|
menu.append(&trash);
|
||||||
|
|
||||||
@@ -1184,7 +1160,7 @@ pub(crate) fn show_bulk_context_menu(
|
|||||||
|
|
||||||
let files_only = entries.iter().all(|e| !e.is_dir);
|
let files_only = entries.iter().all(|e| !e.is_dir);
|
||||||
if files_only && entries.iter().any(|e| !e.pinned) {
|
if files_only && entries.iter().any(|e| !e.pinned) {
|
||||||
let pin = menu_item("Keep offline", "starred-symbolic");
|
let pin = menu_item("Make available offline", "pdfs-offline-symbolic");
|
||||||
let ui_pin = ui.clone();
|
let ui_pin = ui.clone();
|
||||||
let batch = entries.clone();
|
let batch = entries.clone();
|
||||||
let pop = popover.clone();
|
let pop = popover.clone();
|
||||||
@@ -1195,7 +1171,7 @@ pub(crate) fn show_bulk_context_menu(
|
|||||||
menu.append(&pin);
|
menu.append(&pin);
|
||||||
}
|
}
|
||||||
if files_only && entries.iter().any(|e| e.pinned) {
|
if files_only && entries.iter().any(|e| e.pinned) {
|
||||||
let unpin = menu_item("Remove offline copy", "non-starred-symbolic");
|
let unpin = menu_item("Make online only", "pdfs-online-only-symbolic");
|
||||||
let ui_unpin = ui.clone();
|
let ui_unpin = ui.clone();
|
||||||
let batch = entries.clone();
|
let batch = entries.clone();
|
||||||
let pop = popover.clone();
|
let pop = popover.clone();
|
||||||
@@ -1206,14 +1182,18 @@ pub(crate) fn show_bulk_context_menu(
|
|||||||
menu.append(&unpin);
|
menu.append(&unpin);
|
||||||
}
|
}
|
||||||
|
|
||||||
menu.append(>k4::Separator::new(gtk4::Orientation::Horizontal));
|
// Offline items only exist for all-file selections; without them the
|
||||||
|
// separator would sit directly under the header.
|
||||||
|
if files_only {
|
||||||
|
menu.append(>k4::Separator::new(gtk4::Orientation::Horizontal));
|
||||||
|
}
|
||||||
|
|
||||||
let trash = menu_item("Move to Trash", "user-trash-symbolic");
|
let trash = menu_item("Move to Trash", "user-trash-symbolic");
|
||||||
let ui_tr = ui.clone();
|
let ui_tr = ui.clone();
|
||||||
let pop = popover.clone();
|
let pop = popover.clone();
|
||||||
trash.connect_clicked(move |_| {
|
trash.connect_clicked(move |_| {
|
||||||
pop.popdown();
|
pop.popdown();
|
||||||
prompt_delete_many(&ui_tr, entries.clone());
|
trash_entries(&ui_tr, entries.clone());
|
||||||
});
|
});
|
||||||
menu.append(&trash);
|
menu.append(&trash);
|
||||||
|
|
||||||
@@ -1367,7 +1347,7 @@ pub(crate) fn toggle_pin(ui: &Rc<Ui>, entry: &DirEntry) {
|
|||||||
toast(
|
toast(
|
||||||
&ui,
|
&ui,
|
||||||
&if pinned {
|
&if pinned {
|
||||||
format!("“{name}” is no longer kept offline")
|
format!("“{name}” is now online only")
|
||||||
} else {
|
} else {
|
||||||
format!("“{name}” is now available offline")
|
format!("“{name}” is now available offline")
|
||||||
},
|
},
|
||||||
@@ -1651,6 +1631,12 @@ fn repaint_thumbnail_build(ui: &Rc<Ui>, status: &ThumbnailBuildStatus) {
|
|||||||
repaint_thumbnail_build_action(ui, true);
|
repaint_thumbnail_build_action(ui, true);
|
||||||
schedule_thumbnail_build_poll(ui);
|
schedule_thumbnail_build_poll(ui);
|
||||||
} else {
|
} else {
|
||||||
|
// The progress row leaves with the run, so the outcome is handed to a
|
||||||
|
// toast instead — otherwise the build ends without a word. Only on the
|
||||||
|
// running → finished edge: a status read at startup is not news.
|
||||||
|
if ui.browser.thumbnail_build_running.get() {
|
||||||
|
toast(ui, &text);
|
||||||
|
}
|
||||||
ui.browser.thumbnail_build_running.set(false);
|
ui.browser.thumbnail_build_running.set(false);
|
||||||
ui.browser.thumbnail_cancel_pending.set(false);
|
ui.browser.thumbnail_cancel_pending.set(false);
|
||||||
repaint_thumbnail_build_action(ui, false);
|
repaint_thumbnail_build_action(ui, false);
|
||||||
@@ -1829,29 +1815,9 @@ pub(crate) fn prompt_move(ui: &Rc<Ui>, entry: &DirEntry) {
|
|||||||
dialog.present(parent.as_ref());
|
dialog.present(parent.as_ref());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Confirm and move the entry to Trash through the daemon.
|
/// Move one entry to Trash, through the batch path so it gets the same Undo.
|
||||||
pub(crate) fn prompt_delete(ui: &Rc<Ui>, entry: &DirEntry) {
|
pub(crate) fn trash_entry(ui: &Rc<Ui>, entry: &DirEntry) {
|
||||||
let win = ui_window(ui);
|
run_bulk_delete(ui, vec![entry.clone()]);
|
||||||
let dialog = adw::AlertDialog::builder()
|
|
||||||
.heading("Move to Trash")
|
|
||||||
.body(format!("Move “{}” to Trash?", entry.name))
|
|
||||||
.build();
|
|
||||||
dialog.add_response("cancel", "Cancel");
|
|
||||||
dialog.add_response("trash", "Move to Trash");
|
|
||||||
dialog.set_response_appearance("trash", adw::ResponseAppearance::Destructive);
|
|
||||||
dialog.set_default_response(Some("cancel"));
|
|
||||||
dialog.set_close_response("cancel");
|
|
||||||
|
|
||||||
// Routed through the batch path so a single trash gets the same Undo: one
|
|
||||||
// mis-click on one file is no less worth taking back than fifty.
|
|
||||||
let entry = entry.clone();
|
|
||||||
let ui = ui.clone();
|
|
||||||
dialog.connect_response(None, move |_, resp| {
|
|
||||||
if resp == "trash" {
|
|
||||||
run_bulk_delete(&ui, vec![entry.clone()]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
dialog.present(win.as_ref());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Prompt for a folder name and create it under the current browser directory.
|
/// Prompt for a folder name and create it under the current browser directory.
|
||||||
@@ -1978,11 +1944,11 @@ pub(crate) fn badge_for(entry: &DirEntry) -> Option<(&'static str, &'static str)
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
Some(if entry.pinned {
|
Some(if entry.pinned {
|
||||||
("starred-symbolic", "badge-pinned")
|
("pdfs-offline-symbolic", "badge-pinned")
|
||||||
} else if entry.cached {
|
} else if entry.cached {
|
||||||
("emblem-ok-symbolic", "badge-cached")
|
("emblem-ok-symbolic", "badge-cached")
|
||||||
} else {
|
} else {
|
||||||
("weather-overcast-symbolic", "badge-cloud")
|
("pdfs-online-only-symbolic", "badge-cloud")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2340,8 +2306,7 @@ pub(crate) fn repaint_search(ui: &Rc<Ui>, hits: &[SearchHit]) {
|
|||||||
size: h.size,
|
size: h.size,
|
||||||
modified: h.modified,
|
modified: h.modified,
|
||||||
pinned: h.pinned,
|
pinned: h.pinned,
|
||||||
// Search hits don't carry cache state; the badge shows in listings.
|
cached: h.cached,
|
||||||
cached: false,
|
|
||||||
uid: h.uid.clone(),
|
uid: h.uid.clone(),
|
||||||
path: h.path.clone(),
|
path: h.path.clone(),
|
||||||
role: String::new(),
|
role: String::new(),
|
||||||
|
|||||||
@@ -43,9 +43,10 @@ pub(crate) struct DevicesWidgets {
|
|||||||
pub(crate) refresh: gtk4::Button,
|
pub(crate) refresh: gtk4::Button,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The Computers page: a "This computer" section listing the local folders synced
|
/// The Computers page: a "This computer" section naming the device this machine
|
||||||
/// to this machine's device (each row offering a mode toggle and Remove), plus an
|
/// backs up to (renamable from its header), plus an "Other computers" section
|
||||||
/// "Other devices" section listing the account's other registered devices.
|
/// listing the account's other registered devices. The synced folders
|
||||||
|
/// themselves live on the Locations page.
|
||||||
pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
|
pub(crate) fn build_devices_page() -> (gtk4::Widget, DevicesWidgets) {
|
||||||
let title = gtk4::Label::builder()
|
let title = gtk4::Label::builder()
|
||||||
.label("Computers")
|
.label("Computers")
|
||||||
@@ -588,7 +589,7 @@ fn show_restore_picker(ui: &Rc<Ui>, items: Vec<RestorableFolder>) {
|
|||||||
/// Flip a synced folder between `mirror` and `ondemand`. Reloads after so the
|
/// 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
|
/// 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).
|
/// 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: &str) {
|
pub(crate) fn set_sync_folder_mode(ui: &Rc<Ui>, id: i64, mode: &'static str) {
|
||||||
let rx = spawn_request(
|
let rx = spawn_request(
|
||||||
ui.dirs.control_socket(),
|
ui.dirs.control_socket(),
|
||||||
Request::SetSyncFolderMode {
|
Request::SetSyncFolderMode {
|
||||||
@@ -599,7 +600,17 @@ pub(crate) fn set_sync_folder_mode(ui: &Rc<Ui>, id: i64, mode: &str) {
|
|||||||
let ui = ui.clone();
|
let ui = ui.clone();
|
||||||
glib::spawn_future_local(async move {
|
glib::spawn_future_local(async move {
|
||||||
match rx.recv().await {
|
match rx.recv().await {
|
||||||
Ok(Ok(Response::Ok { message })) => toast(&ui, &message),
|
// 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" {
|
||||||
|
"Folder will switch to on-demand"
|
||||||
|
} else {
|
||||||
|
"Folder will download and stay synced"
|
||||||
|
},
|
||||||
|
),
|
||||||
Ok(Ok(Response::Error { message, kind })) => {
|
Ok(Ok(Response::Error { message, kind })) => {
|
||||||
toast_failure(&ui, "Couldn't change mode", &message, kind)
|
toast_failure(&ui, "Couldn't change mode", &message, kind)
|
||||||
}
|
}
|
||||||
@@ -609,12 +620,22 @@ pub(crate) fn set_sync_folder_mode(ui: &Rc<Ui>, id: i64, mode: &str) {
|
|||||||
"The mount service didn't respond.",
|
"The mount service didn't respond.",
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
if ui.stack.visible_child_name().as_deref() == Some("devices") {
|
reload_sync_pages(&ui);
|
||||||
load_devices(&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.
|
/// 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) {
|
pub(crate) fn prompt_remove_sync_folder(ui: &Rc<Ui>, id: i64, path: &str, ondemand: bool) {
|
||||||
let win = ui_window(ui);
|
let win = ui_window(ui);
|
||||||
@@ -790,7 +811,7 @@ pub(crate) fn prompt_adopt_device(ui: &Rc<Ui>, uid: &str, name: &str) {
|
|||||||
dialog.present(win.as_ref());
|
dialog.present(win.as_ref());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run a mutation raised from the Devices page and reload the page on success.
|
/// Run a mutation raised from Computers or Locations and reload the page on success.
|
||||||
pub(crate) fn run_devices_mutation(
|
pub(crate) fn run_devices_mutation(
|
||||||
ui: &Rc<Ui>,
|
ui: &Rc<Ui>,
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -806,7 +827,7 @@ pub(crate) fn run_devices_mutation(
|
|||||||
ui.busy_end();
|
ui.busy_end();
|
||||||
match result {
|
match result {
|
||||||
Ok(Ok(Response::Ok { .. })) => {
|
Ok(Ok(Response::Ok { .. })) => {
|
||||||
load_devices(&ui);
|
reload_sync_pages(&ui);
|
||||||
toast(&ui, &done);
|
toast(&ui, &done);
|
||||||
}
|
}
|
||||||
Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, failed, &message, kind),
|
Ok(Ok(Response::Error { message, kind })) => toast_failure(&ui, failed, &message, kind),
|
||||||
|
|||||||
@@ -327,8 +327,8 @@ pub(crate) fn repaint_locations(ui: &Rc<Ui>, items: &[MountSpec]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The primary mount's only control: where it lives. Changing it rewrites config
|
/// The primary mount's only control: where it lives. Changing it rewrites config
|
||||||
/// and offers a service restart, which is why it stays a single prompt shared
|
/// and offers a service restart, which is why it goes through the shared
|
||||||
/// with the (now removed) Settings row.
|
/// [`prompt_mountpoint`] dialog.
|
||||||
fn add_my_files_controls(ui: &Rc<Ui>, row: &adw::ActionRow) {
|
fn add_my_files_controls(ui: &Rc<Ui>, row: &adw::ActionRow) {
|
||||||
let change = gtk4::Button::builder()
|
let change = gtk4::Button::builder()
|
||||||
.label("Change")
|
.label("Change")
|
||||||
@@ -341,8 +341,8 @@ fn add_my_files_controls(ui: &Rc<Ui>, row: &adw::ActionRow) {
|
|||||||
row.add_suffix(&change);
|
row.add_suffix(&change);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sync now (mirror only), the on-demand switch, and Remove — the same three
|
/// Sync now (mirror only), the on-demand switch, and Stop syncing for one
|
||||||
/// controls, and the same handlers, the Computers page used to carry.
|
/// synced folder.
|
||||||
fn add_device_controls(ui: &Rc<Ui>, row: &adw::ActionRow, spec: &MountSpec, id: i64) {
|
fn add_device_controls(ui: &Rc<Ui>, row: &adw::ActionRow, spec: &MountSpec, id: i64) {
|
||||||
if spec.mode != MountMode::OnDemand {
|
if spec.mode != MountMode::OnDemand {
|
||||||
let sync_now = gtk4::Button::builder()
|
let sync_now = gtk4::Button::builder()
|
||||||
@@ -371,14 +371,23 @@ fn add_device_controls(ui: &Rc<Ui>, row: &adw::ActionRow, spec: &MountSpec, id:
|
|||||||
.active(target == MountMode::OnDemand)
|
.active(target == MountMode::OnDemand)
|
||||||
.build();
|
.build();
|
||||||
let ui_mode = ui.clone();
|
let ui_mode = ui.clone();
|
||||||
|
let mode_path = spec.local_path.clone();
|
||||||
ondemand.connect_state_set(move |_, on| {
|
ondemand.connect_state_set(move |_, on| {
|
||||||
set_sync_folder_mode(&ui_mode, id, if on { "ondemand" } else { "mirror" });
|
if on {
|
||||||
glib::Propagation::Proceed
|
// Going on-demand deletes the local copies, so it is asked for rather
|
||||||
|
// than done on a flick of the switch. Stop leaves the switch's state
|
||||||
|
// alone; the next repaint paints whatever the daemon then reports.
|
||||||
|
confirm_ondemand(&ui_mode, id, &mode_path);
|
||||||
|
glib::Propagation::Stop
|
||||||
|
} else {
|
||||||
|
set_sync_folder_mode(&ui_mode, id, "mirror");
|
||||||
|
glib::Propagation::Proceed
|
||||||
|
}
|
||||||
});
|
});
|
||||||
row.add_suffix(&ondemand);
|
row.add_suffix(&ondemand);
|
||||||
|
|
||||||
let remove = gtk4::Button::builder()
|
let remove = gtk4::Button::builder()
|
||||||
.icon_name("user-trash-symbolic")
|
.icon_name("media-playback-stop-symbolic")
|
||||||
.tooltip_text("Stop syncing this folder")
|
.tooltip_text("Stop syncing this folder")
|
||||||
.valign(gtk4::Align::Center)
|
.valign(gtk4::Align::Center)
|
||||||
.build();
|
.build();
|
||||||
@@ -392,6 +401,33 @@ fn add_device_controls(ui: &Rc<Ui>, row: &adw::ActionRow, spec: &MountSpec, id:
|
|||||||
row.add_suffix(&remove);
|
row.add_suffix(&remove);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ask before switching a synced folder to on-demand: the switch frees disk
|
||||||
|
/// space by removing the local copies, which is not something to do by accident.
|
||||||
|
fn confirm_ondemand(ui: &Rc<Ui>, id: i64, path: &str) {
|
||||||
|
let dialog = adw::AlertDialog::builder()
|
||||||
|
.heading("Make Folder On-Demand?")
|
||||||
|
.body(format!(
|
||||||
|
"Files in {path} will be removed from this computer and kept in Proton \
|
||||||
|
Drive only. Each file downloads again when you open it."
|
||||||
|
))
|
||||||
|
.build();
|
||||||
|
dialog.add_response("cancel", "Cancel");
|
||||||
|
dialog.add_response("ondemand", "Make On-Demand");
|
||||||
|
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");
|
||||||
|
} else {
|
||||||
|
refresh_locations(&ui);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
dialog.present(win.as_ref());
|
||||||
|
}
|
||||||
|
|
||||||
/// Ask the daemon for an immediate pass over one folder.
|
/// Ask the daemon for an immediate pass over one folder.
|
||||||
fn sync_folder_now(ui: &Rc<Ui>, id: i64) {
|
fn sync_folder_now(ui: &Rc<Ui>, id: i64) {
|
||||||
let rx = spawn_request(ui.dirs.control_socket(), Request::SyncNow { id: Some(id) });
|
let rx = spawn_request(ui.dirs.control_socket(), Request::SyncNow { id: Some(id) });
|
||||||
|
|||||||
@@ -268,17 +268,27 @@ pub(crate) fn prompt_2fa(ui: &Rc<Ui>, code_tx: std::sync::mpsc::Sender<String>)
|
|||||||
dialog.present(parent.as_ref());
|
dialog.present(parent.as_ref());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Connect the sign-out button: disable+stop the mount service (so the daemon
|
/// Connect the sign-out button: after a confirmation, disable+stop the mount
|
||||||
/// isn't left running without credentials), forget the stored session, and drop
|
/// service (so the daemon isn't left running without credentials), forget the
|
||||||
/// back to the login page.
|
/// stored session, and drop back to the login page.
|
||||||
pub(crate) fn wire_logout(ui: &Rc<Ui>, button: >k4::Button) {
|
pub(crate) fn wire_logout(ui: &Rc<Ui>, button: >k4::Button) {
|
||||||
let ui = ui.clone();
|
let ui = ui.clone();
|
||||||
button.connect_clicked(move |_| {
|
button.connect_clicked(move |btn| {
|
||||||
service::disable_stop();
|
let ui = ui.clone();
|
||||||
if let Err(e) = auth::logout() {
|
confirm_destructive(
|
||||||
tracing::error!("logout failed: {e}");
|
btn,
|
||||||
}
|
"Sign Out?",
|
||||||
*ui.session.borrow_mut() = None;
|
"Proton Drive will disconnect and stop syncing until you sign in again. \
|
||||||
refresh(&ui);
|
Files already on this computer stay where they are.",
|
||||||
|
"Sign Out",
|
||||||
|
move || {
|
||||||
|
service::disable_stop();
|
||||||
|
if let Err(e) = auth::logout() {
|
||||||
|
tracing::error!("logout failed: {e}");
|
||||||
|
}
|
||||||
|
*ui.session.borrow_mut() = None;
|
||||||
|
refresh(&ui);
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -592,8 +592,12 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
|
|||||||
let info_rows = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
let info_rows = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||||
|
|
||||||
let info_map = gtk4::Button::builder()
|
let info_map = gtk4::Button::builder()
|
||||||
.label("Show on map")
|
.child(
|
||||||
.icon_name("map-symbolic")
|
&adw::ButtonContent::builder()
|
||||||
|
.label("Show on map")
|
||||||
|
.icon_name("map-symbolic")
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
.halign(gtk4::Align::Start)
|
.halign(gtk4::Align::Start)
|
||||||
.build();
|
.build();
|
||||||
info_map.add_css_class("pill");
|
info_map.add_css_class("pill");
|
||||||
|
|||||||
@@ -423,9 +423,16 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
|
|||||||
back.add_css_class("flat");
|
back.add_css_class("flat");
|
||||||
back.add_css_class("circular");
|
back.add_css_class("circular");
|
||||||
|
|
||||||
|
// `label` and `icon_name` both replace a button's child, so the pair needs
|
||||||
|
// an `adw::ButtonContent` to show together.
|
||||||
let upload = gtk4::Button::builder()
|
let upload = gtk4::Button::builder()
|
||||||
.label("Upload")
|
.child(
|
||||||
.icon_name("list-add-symbolic")
|
&adw::ButtonContent::builder()
|
||||||
|
.label("Upload")
|
||||||
|
.icon_name("list-add-symbolic")
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.tooltip_text("Upload photos")
|
||||||
.valign(gtk4::Align::Center)
|
.valign(gtk4::Align::Center)
|
||||||
.build();
|
.build();
|
||||||
upload.add_css_class("pill");
|
upload.add_css_class("pill");
|
||||||
|
|||||||
@@ -541,8 +541,17 @@ pub(crate) fn repaint_shared(
|
|||||||
});
|
});
|
||||||
let ui_rej = ui.clone();
|
let ui_rej = ui.clone();
|
||||||
let id_rej = inv.id.clone();
|
let id_rej = inv.id.clone();
|
||||||
reject.connect_clicked(move |_| {
|
let name_rej = inv.name.clone().unwrap_or_else(|| "this item".into());
|
||||||
respond_invitation(&ui_rej, &id_rej, false);
|
reject.connect_clicked(move |btn| {
|
||||||
|
let ui = ui_rej.clone();
|
||||||
|
let id = id_rej.clone();
|
||||||
|
confirm_destructive(
|
||||||
|
btn,
|
||||||
|
"Reject Invitation?",
|
||||||
|
&format!("You won't have access to {name_rej} unless it is shared again."),
|
||||||
|
"Reject",
|
||||||
|
move || respond_invitation(&ui, &id, false),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
row.add_suffix(&accept);
|
row.add_suffix(&accept);
|
||||||
row.add_suffix(&reject);
|
row.add_suffix(&reject);
|
||||||
|
|||||||
@@ -113,8 +113,6 @@ pub(crate) struct MainWidgets {
|
|||||||
/// which is where the folder is changed.
|
/// which is where the folder is changed.
|
||||||
pub(crate) mountpoint_row: adw::ActionRow,
|
pub(crate) mountpoint_row: adw::ActionRow,
|
||||||
pub(crate) mountpoint_button: gtk4::Button,
|
pub(crate) mountpoint_button: gtk4::Button,
|
||||||
/// Opens the Google Photos Takeout import page.
|
|
||||||
pub(crate) import_row: adw::ActionRow,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The main (logged-in) page: a libadwaita settings surface — account header,
|
/// The main (logged-in) page: a libadwaita settings surface — account header,
|
||||||
@@ -244,22 +242,6 @@ pub(crate) fn build_main_page() -> (gtk4::Widget, MainWidgets) {
|
|||||||
mountpoint_row.add_suffix(&mountpoint_button);
|
mountpoint_row.add_suffix(&mountpoint_button);
|
||||||
system_group.add(&mountpoint_row);
|
system_group.add(&mountpoint_row);
|
||||||
|
|
||||||
// Import: a one-off migration rather than a setting, so it is a doorway to
|
|
||||||
// its own page rather than controls inlined here — staging a set of archives
|
|
||||||
// and watching an hours-long upload needs the room.
|
|
||||||
let import_group = adw::PreferencesGroup::builder()
|
|
||||||
.title("Import")
|
|
||||||
.description("Bring a photo library from another service into Proton Photos.")
|
|
||||||
.build();
|
|
||||||
let import_row = adw::ActionRow::builder()
|
|
||||||
.title("Import from Google Photos")
|
|
||||||
.subtitle("Add a Google Takeout export to your timeline.")
|
|
||||||
.activatable(true)
|
|
||||||
.build();
|
|
||||||
import_row.add_prefix(>k4::Image::from_icon_name("folder-download-symbolic"));
|
|
||||||
import_row.add_suffix(>k4::Image::from_icon_name("go-next-symbolic"));
|
|
||||||
import_group.add(&import_row);
|
|
||||||
|
|
||||||
// Pins group: filled in by refresh.
|
// Pins group: filled in by refresh.
|
||||||
let pins_group = adw::PreferencesGroup::builder()
|
let pins_group = adw::PreferencesGroup::builder()
|
||||||
.title("Pinned files")
|
.title("Pinned files")
|
||||||
@@ -292,7 +274,6 @@ pub(crate) fn build_main_page() -> (gtk4::Widget, MainWidgets) {
|
|||||||
inner.append(&transfers_group);
|
inner.append(&transfers_group);
|
||||||
inner.append(&storage_group);
|
inner.append(&storage_group);
|
||||||
inner.append(&system_group);
|
inner.append(&system_group);
|
||||||
inner.append(&import_group);
|
|
||||||
inner.append(&pins_group);
|
inner.append(&pins_group);
|
||||||
inner.append(&dev_group);
|
inner.append(&dev_group);
|
||||||
|
|
||||||
@@ -320,7 +301,6 @@ pub(crate) fn build_main_page() -> (gtk4::Widget, MainWidgets) {
|
|||||||
purge_button,
|
purge_button,
|
||||||
mountpoint_row,
|
mountpoint_row,
|
||||||
mountpoint_button,
|
mountpoint_button,
|
||||||
import_row,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -346,7 +326,6 @@ pub(crate) fn wire_settings(
|
|||||||
ui: &Rc<Ui>,
|
ui: &Rc<Ui>,
|
||||||
purge_button: >k4::Button,
|
purge_button: >k4::Button,
|
||||||
mountpoint_button: >k4::Button,
|
mountpoint_button: >k4::Button,
|
||||||
import_row: &adw::ActionRow,
|
|
||||||
) {
|
) {
|
||||||
let config = ui.dirs.load_config();
|
let config = ui.dirs.load_config();
|
||||||
|
|
||||||
@@ -430,10 +409,6 @@ pub(crate) fn wire_settings(
|
|||||||
// local path; this button is the way there.
|
// local path; this button is the way there.
|
||||||
let ui_mp = ui.clone();
|
let ui_mp = ui.clone();
|
||||||
mountpoint_button.connect_clicked(move |_| ui_mp.stack.set_visible_child_name("locations"));
|
mountpoint_button.connect_clicked(move |_| ui_mp.stack.set_visible_child_name("locations"));
|
||||||
|
|
||||||
// Import: a doorway, not a setting — everything about it lives on its page.
|
|
||||||
let ui_import = ui.clone();
|
|
||||||
import_row.connect_activated(move |_| ui_import.stack.set_visible_child_name("takeout"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run a settings control-socket round-trip (budget / purge) on a worker thread,
|
/// Run a settings control-socket round-trip (budget / purge) on a worker thread,
|
||||||
@@ -693,6 +668,7 @@ pub(crate) fn set_mounted(ui: &Rc<Ui>, mounted: bool) {
|
|||||||
ui.details.details.rename_button.set_sensitive(mounted);
|
ui.details.details.rename_button.set_sensitive(mounted);
|
||||||
ui.details.details.trash_button.set_sensitive(mounted);
|
ui.details.details.trash_button.set_sensitive(mounted);
|
||||||
ui.details.details.open_button.set_sensitive(mounted);
|
ui.details.details.open_button.set_sensitive(mounted);
|
||||||
|
ui.details.details.versions_button.set_sensitive(mounted);
|
||||||
|
|
||||||
// Only notify on a real edge, and never for the first reading: at startup the
|
// Only notify on a real edge, and never for the first reading: at startup the
|
||||||
// service is usually still coming up, and "disconnected" would be a lie.
|
// service is usually still coming up, and "disconnected" would be a lie.
|
||||||
@@ -1000,7 +976,7 @@ pub(crate) fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted:
|
|||||||
let unpin = gtk4::Button::builder()
|
let unpin = gtk4::Button::builder()
|
||||||
.icon_name("user-trash-symbolic")
|
.icon_name("user-trash-symbolic")
|
||||||
.valign(gtk4::Align::Center)
|
.valign(gtk4::Align::Center)
|
||||||
.tooltip_text("Unpin (remove offline copy)")
|
.tooltip_text("Make online only")
|
||||||
.sensitive(mounted)
|
.sensitive(mounted)
|
||||||
.build();
|
.build();
|
||||||
unpin.add_css_class("flat");
|
unpin.add_css_class("flat");
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
//! The Import page: bringing a Google Photos **Takeout** export into Proton
|
//! The Import page: bringing a Google Photos **Takeout** export into Proton
|
||||||
//! Photos.
|
//! Photos.
|
||||||
//!
|
//!
|
||||||
//! Reached from Settings → *Import from Google Photos* (and from the Gallery's
|
//! Reached from the Photos page's import button (and its empty timeline),
|
||||||
//! import button), because it is a one-off migration rather than something the
|
//! because it is a one-off migration rather than something the
|
||||||
//! photo timeline does every day. It gets a page of its own instead of a dialog
|
//! photo timeline does every day. It gets a page of its own instead of a dialog
|
||||||
//! for two reasons: an export is a *set* of archives that has to be staged and
|
//! for two reasons: an export is a *set* of archives that has to be staged and
|
||||||
//! checked before anything is sent, and the import that follows runs for hours,
|
//! checked before anything is sent, and the import that follows runs for hours,
|
||||||
@@ -126,11 +126,11 @@ pub(crate) struct TakeoutWidgets {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
|
pub(crate) fn build_takeout_page() -> (gtk4::Widget, TakeoutWidgets) {
|
||||||
// Header: a back button out to Settings, since this page is reached from
|
// Header: a back button out to Photos, since this page is reached from
|
||||||
// there and has no sidebar row of its own to navigate back with.
|
// there and has no sidebar row of its own to navigate back with.
|
||||||
let back_button = gtk4::Button::builder()
|
let back_button = gtk4::Button::builder()
|
||||||
.icon_name("go-previous-symbolic")
|
.icon_name("go-previous-symbolic")
|
||||||
.tooltip_text("Back to Settings")
|
.tooltip_text("Back to Photos")
|
||||||
.valign(gtk4::Align::Center)
|
.valign(gtk4::Align::Center)
|
||||||
.build();
|
.build();
|
||||||
back_button.add_css_class("flat");
|
back_button.add_css_class("flat");
|
||||||
@@ -386,9 +386,10 @@ pub(crate) fn wire_takeout(ui: &Rc<Ui>, widgets: &TakeoutWidgets) {
|
|||||||
let dialog = adw::AlertDialog::builder()
|
let dialog = adw::AlertDialog::builder()
|
||||||
.heading("Import this export?")
|
.heading("Import this export?")
|
||||||
.body(format!(
|
.body(format!(
|
||||||
"{count} archive(s) will be uploaded to Proton Photos, creating albums as they \
|
"{} will be uploaded to Proton Photos, creating albums as they appear in \
|
||||||
appear in the export. This can take hours; you can stop it at any point and \
|
the export. This can take hours; you can stop it at any point and what has \
|
||||||
what has been uploaded stays. Photos you already have are skipped.",
|
been uploaded stays. Photos you already have are skipped.",
|
||||||
|
count_noun(count, "archive", "archives")
|
||||||
))
|
))
|
||||||
.build();
|
.build();
|
||||||
dialog.add_response("cancel", "Cancel");
|
dialog.add_response("cancel", "Cancel");
|
||||||
@@ -429,7 +430,7 @@ pub(crate) fn wire_takeout(ui: &Rc<Ui>, widgets: &TakeoutWidgets) {
|
|||||||
let ui_back = ui.clone();
|
let ui_back = ui.clone();
|
||||||
widgets
|
widgets
|
||||||
.back_button
|
.back_button
|
||||||
.connect_clicked(move |_| ui_back.stack.set_visible_child_name("main"));
|
.connect_clicked(move |_| ui_back.stack.set_visible_child_name("gallery"));
|
||||||
|
|
||||||
// Restore whatever was staged when the window last closed, before the first
|
// Restore whatever was staged when the window last closed, before the first
|
||||||
// paint, so the page comes up as the user left it.
|
// paint, so the page comes up as the user left it.
|
||||||
@@ -473,7 +474,10 @@ pub(crate) fn add_archives(ui: &Rc<Ui>, paths: Vec<PathBuf>) {
|
|||||||
"Only Takeout .zip files can be imported — extracted folders aren't supported.",
|
"Only Takeout .zip files can be imported — extracted folders aren't supported.",
|
||||||
);
|
);
|
||||||
} else if added > 0 {
|
} else if added > 0 {
|
||||||
toast(ui, &format!("Added {added} archive(s)"));
|
toast(
|
||||||
|
ui,
|
||||||
|
&format!("Added {}", count_noun(added, "archive", "archives")),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,8 +530,8 @@ pub(crate) fn repaint_archives(ui: &Rc<Ui>) {
|
|||||||
rows.push(row.upcast());
|
rows.push(row.upcast());
|
||||||
}
|
}
|
||||||
ui.takeout.list_group.set_description(Some(&format!(
|
ui.takeout.list_group.set_description(Some(&format!(
|
||||||
"{} archive(s), {} in total. {EMPTY_LIST_DESCRIPTION}",
|
"{}, {} in total. {EMPTY_LIST_DESCRIPTION}",
|
||||||
staged.len(),
|
count_noun(staged.len(), "archive", "archives"),
|
||||||
human_bytes(total)
|
human_bytes(total)
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -378,7 +378,8 @@ pub(crate) fn prompt_empty_trash(ui: &Rc<Ui>) {
|
|||||||
let dialog = adw::AlertDialog::builder()
|
let dialog = adw::AlertDialog::builder()
|
||||||
.heading("Empty Trash")
|
.heading("Empty Trash")
|
||||||
.body(format!(
|
.body(format!(
|
||||||
"Permanently delete all {count} item(s) in the trash? This cannot be undone."
|
"Permanently delete all {} in the trash? This cannot be undone.",
|
||||||
|
count_noun(count as usize, "item", "items")
|
||||||
))
|
))
|
||||||
.build();
|
.build();
|
||||||
dialog.add_response("cancel", "Cancel");
|
dialog.add_response("cancel", "Cancel");
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ pub(crate) fn build_details_pane() -> (gtk4::Widget, DetailsWidgets) {
|
|||||||
let offline = adw::PreferencesGroup::new();
|
let offline = adw::PreferencesGroup::new();
|
||||||
let pin_row = adw::SwitchRow::builder()
|
let pin_row = adw::SwitchRow::builder()
|
||||||
.title("Available offline")
|
.title("Available offline")
|
||||||
.subtitle("Keep a local copy on this device.")
|
.subtitle("Keep a copy on this computer, even without a connection.")
|
||||||
.build();
|
.build();
|
||||||
offline.add(&pin_row);
|
offline.add(&pin_row);
|
||||||
|
|
||||||
@@ -208,7 +208,7 @@ pub(crate) fn wire_details(ui: &Rc<Ui>) {
|
|||||||
ui.details.details.trash_button.connect_clicked(move |_| {
|
ui.details.details.trash_button.connect_clicked(move |_| {
|
||||||
let entry = ui_trash.details.details_entry.borrow().clone();
|
let entry = ui_trash.details.details_entry.borrow().clone();
|
||||||
if let Some(entry) = entry {
|
if let Some(entry) = entry {
|
||||||
prompt_delete(&ui_trash, &entry);
|
trash_entry(&ui_trash, &entry);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let ui_pin = ui.clone();
|
let ui_pin = ui.clone();
|
||||||
@@ -256,7 +256,10 @@ pub(crate) fn show_details(ui: &Rc<Ui>, entry: &DirEntry) {
|
|||||||
} else {
|
} else {
|
||||||
parent
|
parent
|
||||||
});
|
});
|
||||||
|
// Pinning is per file: the context menu and the selection bar offer it for
|
||||||
|
// files only, so the pane must not offer it for folders either.
|
||||||
d.pin_row.set_active(entry.pinned);
|
d.pin_row.set_active(entry.pinned);
|
||||||
|
d.pin_row.set_visible(!entry.is_dir);
|
||||||
d.pin_row.set_sensitive(*ui.mounted.borrow());
|
d.pin_row.set_sensitive(*ui.mounted.borrow());
|
||||||
d.open_button
|
d.open_button
|
||||||
.set_label(if entry.is_dir { "Open folder" } else { "Open" });
|
.set_label(if entry.is_dir { "Open folder" } else { "Open" });
|
||||||
|
|||||||
@@ -361,13 +361,24 @@ pub(crate) fn repaint_share_people(state: &Rc<ShareDialog>, entries: &[ShareEntr
|
|||||||
let state_rm = state.clone();
|
let state_rm = state.clone();
|
||||||
let id = entry.id.clone();
|
let id = entry.id.clone();
|
||||||
let kind = entry.kind;
|
let kind = entry.kind;
|
||||||
remove.connect_clicked(move |_| {
|
let who = entry.email.clone();
|
||||||
share_dialog_op(
|
remove.connect_clicked(move |btn| {
|
||||||
&state_rm,
|
let state = state_rm.clone();
|
||||||
state_rm.target.remove_entry(id.clone(), kind),
|
let id = id.clone();
|
||||||
"Access removed",
|
confirm_destructive(
|
||||||
"Couldn't remove access",
|
btn,
|
||||||
None,
|
"Remove Access?",
|
||||||
|
&format!("{who} will no longer be able to open this item."),
|
||||||
|
"Remove",
|
||||||
|
move || {
|
||||||
|
share_dialog_op(
|
||||||
|
&state,
|
||||||
|
state.target.remove_entry(id.clone(), kind),
|
||||||
|
"Access removed",
|
||||||
|
"Couldn't remove access",
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
row.add_suffix(&remove);
|
row.add_suffix(&remove);
|
||||||
@@ -390,9 +401,12 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
|||||||
Some(link) => {
|
Some(link) => {
|
||||||
let url = link.url.clone().unwrap_or_default();
|
let url = link.url.clone().unwrap_or_default();
|
||||||
let subtitle = if link.has_password {
|
let subtitle = if link.has_password {
|
||||||
format!("Anyone with the link ({}) · password-protected", link.role)
|
format!(
|
||||||
|
"Anyone with the link ({}) · password-protected",
|
||||||
|
capitalize(&link.role)
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
format!("Anyone with the link ({})", link.role)
|
format!("Anyone with the link ({})", capitalize(&link.role))
|
||||||
};
|
};
|
||||||
let row = adw::ActionRow::builder()
|
let row = adw::ActionRow::builder()
|
||||||
.title(if url.is_empty() { "Public link" } else { &url })
|
.title(if url.is_empty() { "Public link" } else { &url })
|
||||||
@@ -424,13 +438,24 @@ pub(crate) fn repaint_share_link(state: &Rc<ShareDialog>, link: Option<&PublicLi
|
|||||||
remove.add_css_class("flat");
|
remove.add_css_class("flat");
|
||||||
let state_rm = state.clone();
|
let state_rm = state.clone();
|
||||||
let id = link.id.clone();
|
let id = link.id.clone();
|
||||||
remove.connect_clicked(move |_| {
|
remove.connect_clicked(move |btn| {
|
||||||
share_dialog_op(
|
let state = state_rm.clone();
|
||||||
&state_rm,
|
let id = id.clone();
|
||||||
state_rm.target.remove_link(id.clone()),
|
confirm_destructive(
|
||||||
"Public link removed",
|
btn,
|
||||||
"Couldn't remove the link",
|
"Remove Public Link?",
|
||||||
None,
|
"The link stops working for everyone who has it. A new link will have \
|
||||||
|
a different address.",
|
||||||
|
"Remove Link",
|
||||||
|
move || {
|
||||||
|
share_dialog_op(
|
||||||
|
&state,
|
||||||
|
state.target.remove_link(id.clone()),
|
||||||
|
"Public link removed",
|
||||||
|
"Couldn't remove the link",
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
row.add_suffix(&remove);
|
row.add_suffix(&remove);
|
||||||
|
|||||||
@@ -182,16 +182,20 @@ fn repaint_versions(state: &Rc<VersionsDialog>, items: &[RevisionInfo]) {
|
|||||||
.filter(|size| *size >= 0)
|
.filter(|size| *size >= 0)
|
||||||
.unwrap_or(item.size_on_storage)
|
.unwrap_or(item.size_on_storage)
|
||||||
.max(0) as u64;
|
.max(0) as u64;
|
||||||
let mut subtitle = format!("{} · {}", activity_time(item.created), human_bytes(size));
|
// A revision id means nothing to a person; name earlier versions by
|
||||||
|
// when they were saved instead.
|
||||||
|
let (title, mut subtitle) = if item.is_active {
|
||||||
|
(
|
||||||
|
"Current version".to_string(),
|
||||||
|
format!("{} · {}", activity_time(item.created), human_bytes(size)),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(activity_time(item.created), human_bytes(size))
|
||||||
|
};
|
||||||
if let Some(email) = &item.signed_by {
|
if let Some(email) = &item.signed_by {
|
||||||
subtitle.push_str(" · ");
|
subtitle.push_str(" · ");
|
||||||
subtitle.push_str(email);
|
subtitle.push_str(email);
|
||||||
}
|
}
|
||||||
let title = if item.is_active {
|
|
||||||
"Current version".to_string()
|
|
||||||
} else {
|
|
||||||
format!("Version {}", item.id)
|
|
||||||
};
|
|
||||||
let row = adw::ActionRow::builder()
|
let row = adw::ActionRow::builder()
|
||||||
.title(title)
|
.title(title)
|
||||||
.subtitle(subtitle)
|
.subtitle(subtitle)
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ mod tests {
|
|||||||
size: 0,
|
size: 0,
|
||||||
modified: 0,
|
modified: 0,
|
||||||
pinned: false,
|
pinned: false,
|
||||||
|
cached: false,
|
||||||
uid: "uid".into(),
|
uid: "uid".into(),
|
||||||
mounted_path: None,
|
mounted_path: None,
|
||||||
score: 0,
|
score: 0,
|
||||||
|
|||||||
@@ -268,6 +268,7 @@ mod tests {
|
|||||||
size: 7,
|
size: 7,
|
||||||
modified: 9,
|
modified: 9,
|
||||||
pinned: false,
|
pinned: false,
|
||||||
|
cached: false,
|
||||||
uid: "uid~1".into(),
|
uid: "uid~1".into(),
|
||||||
mounted_path: None,
|
mounted_path: None,
|
||||||
score: 42,
|
score: 42,
|
||||||
|
|||||||
@@ -908,6 +908,7 @@ impl Ui {
|
|||||||
size: 0,
|
size: 0,
|
||||||
modified: 0,
|
modified: 0,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
|
cached: false,
|
||||||
uid: pin.uid,
|
uid: pin.uid,
|
||||||
mounted_path: None,
|
mounted_path: None,
|
||||||
score: 0,
|
score: 0,
|
||||||
@@ -1613,6 +1614,7 @@ mod tests {
|
|||||||
size: 1,
|
size: 1,
|
||||||
modified: 1,
|
modified: 1,
|
||||||
pinned: false,
|
pinned: false,
|
||||||
|
cached: false,
|
||||||
uid: "v~a".into(),
|
uid: "v~a".into(),
|
||||||
mounted_path: None,
|
mounted_path: None,
|
||||||
score: 100,
|
score: 100,
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ pub(crate) fn pins(socket: &Path) -> Result<Vec<Hit>, String> {
|
|||||||
size: 0,
|
size: 0,
|
||||||
modified: 0,
|
modified: 0,
|
||||||
pinned: true,
|
pinned: true,
|
||||||
|
cached: false,
|
||||||
uid: pin.uid,
|
uid: pin.uid,
|
||||||
mounted_path: None,
|
mounted_path: None,
|
||||||
score: 0,
|
score: 0,
|
||||||
@@ -178,6 +179,7 @@ mod tests {
|
|||||||
size: 0,
|
size: 0,
|
||||||
modified: 0,
|
modified: 0,
|
||||||
pinned: false,
|
pinned: false,
|
||||||
|
cached: false,
|
||||||
uid: "uid".into(),
|
uid: "uid".into(),
|
||||||
mounted_path: None,
|
mounted_path: None,
|
||||||
score: 0,
|
score: 0,
|
||||||
|
|||||||
Reference in new issue
Block a user