mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
feat(core,gui): keep pages up while they reload
Pages no longer swap their content for a "Loading…" status on every load. A new Loader keeps the last rows on screen and only shows the loading state if the reply takes longer than 300 ms; a newer load always supersedes an older one, so a late reply is dropped instead of painted. Reloading what is on screen keeps it usable; loading something else greys the old rows out and clears the selection, since their paths would resolve against the new folder. My files, search results and Trash now swap only the rows that changed, so selection, scroll position and thumbnails survive a refresh. Activity is one sectioned ListView: open conflicts first, then a section per day, with the filter bar pinned above the list. Polls give up after 5 s instead of 120 s. A daemon that is running but doesn't answer now reads as "Not responding" in the app and the tray rather than as disconnected.
This commit is contained in:
1 parent
bd4f797d1f
commit
2339b17d97
23 files changed
+6557
-5852
No files matched your search
@@ -31,6 +31,13 @@ const WRITE_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
/// answers.
|
||||
const READ_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
|
||||
/// Cap on the read half for the requests front ends poll ([`Request::is_poll`]).
|
||||
/// The daemon answers those from memory or its database, so a reply that takes
|
||||
/// longer than this means it is stuck. Waiting the full [`READ_TIMEOUT`] would
|
||||
/// freeze the poller's read-out for two minutes; failing fast lets it say "not
|
||||
/// responding" and ask again on its next tick.
|
||||
const POLL_READ_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
/// A command sent from the CLI to the daemon.
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub enum Request {
|
||||
@@ -739,7 +746,7 @@ pub struct DeviceInfo {
|
||||
|
||||
/// One `(sync-conflict …)` copy and the file it is a copy of (in
|
||||
/// [`Response::Conflicts`]).
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct ConflictInfo {
|
||||
/// Mountpoint-relative path of the copy, or its absolute local path when it
|
||||
/// sits in a synced folder.
|
||||
@@ -1025,7 +1032,7 @@ pub enum ActivityKind {
|
||||
/// One line in the daemon's activity log (in [`Response::Activity`]). Newest
|
||||
/// first. Records a mutation or transfer the daemon performed, with enough
|
||||
/// context to read as a sentence: "Uploaded report.pdf to /docs".
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct ActivityEntry {
|
||||
/// When it happened, epoch seconds.
|
||||
pub time: i64,
|
||||
@@ -1083,7 +1090,7 @@ pub struct TransferItem {
|
||||
}
|
||||
|
||||
/// One entry in a [`Request::ListDir`] listing.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct DirEntry {
|
||||
/// Decrypted node name.
|
||||
pub name: String,
|
||||
@@ -2046,13 +2053,45 @@ impl ErrorKind {
|
||||
}
|
||||
}
|
||||
|
||||
impl Request {
|
||||
/// Whether this is one of the cheap state reads that front ends repeat on a
|
||||
/// timer. The daemon answers these without touching the network, so they
|
||||
/// get the short [`POLL_READ_TIMEOUT`].
|
||||
pub fn is_poll(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Request::Status
|
||||
| Request::GetQueueStatus
|
||||
| Request::ListLocations
|
||||
| Request::ListPendingOps
|
||||
| Request::ListConflicts
|
||||
| Request::ListActivity { .. }
|
||||
| Request::ThumbnailBuildStatus
|
||||
| Request::PhotosRefreshStatus
|
||||
| Request::RedateStatus
|
||||
| Request::ImportStatus
|
||||
)
|
||||
}
|
||||
|
||||
/// How long to wait for the daemon's reply to this request.
|
||||
pub fn read_timeout(&self) -> Duration {
|
||||
if self.is_poll() {
|
||||
POLL_READ_TIMEOUT
|
||||
} else {
|
||||
READ_TIMEOUT
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send one [`Request`] to the daemon listening on `socket` and read its
|
||||
/// [`Response`]. Returns a crate [`crate::Error`] if no daemon is listening.
|
||||
/// [`Response`]. Returns a crate [`crate::Error`] if no daemon is listening, and
|
||||
/// one for which [`crate::Error::is_timeout`] holds if the daemon took longer
|
||||
/// than [`Request::read_timeout`] to answer.
|
||||
///
|
||||
/// Shared by the CLI and GUI so both speak the wire format identically.
|
||||
pub fn send(socket: &Path, req: &Request) -> Result<Response> {
|
||||
let mut stream = UnixStream::connect(socket)?;
|
||||
stream.set_read_timeout(Some(READ_TIMEOUT))?;
|
||||
stream.set_read_timeout(Some(req.read_timeout()))?;
|
||||
stream.set_write_timeout(Some(WRITE_TIMEOUT))?;
|
||||
let mut line = serde_json::to_vec(req)?;
|
||||
line.push(b'\n');
|
||||
@@ -2068,6 +2107,36 @@ pub fn send(socket: &Path, req: &Request) -> Result<Response> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn polls_wait_briefly_and_real_work_waits_long() {
|
||||
assert_eq!(Request::Status.read_timeout(), POLL_READ_TIMEOUT);
|
||||
assert_eq!(
|
||||
Request::ListActivity { limit: 200 }.read_timeout(),
|
||||
POLL_READ_TIMEOUT
|
||||
);
|
||||
assert_eq!(
|
||||
Request::OpenFile {
|
||||
path: "a.txt".into(),
|
||||
uid: None,
|
||||
}
|
||||
.read_timeout(),
|
||||
READ_TIMEOUT
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_daemon_that_never_answers_times_out_as_not_responding() {
|
||||
let socket = std::env::temp_dir().join(format!("pdfs-silent-{}.sock", std::process::id()));
|
||||
let _ = std::fs::remove_file(&socket);
|
||||
// Bound but never accepted: connect succeeds, no reply ever comes.
|
||||
let _listener = std::os::unix::net::UnixListener::bind(&socket).unwrap();
|
||||
let started = std::time::Instant::now();
|
||||
let err = send(&socket, &Request::Status).unwrap_err();
|
||||
let _ = std::fs::remove_file(&socket);
|
||||
assert!(err.is_timeout(), "{err}");
|
||||
assert!(started.elapsed() < READ_TIMEOUT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn photo_kind_classifies_by_extension_then_mime() {
|
||||
use PhotoKind::*;
|
||||
|
||||
@@ -66,6 +66,20 @@ impl Error {
|
||||
}
|
||||
}
|
||||
|
||||
impl Error {
|
||||
/// Whether a control-socket round-trip gave up waiting for the reply: the
|
||||
/// daemon is running but stuck, as opposed to not running at all.
|
||||
pub fn is_timeout(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Error::Io(e) if matches!(
|
||||
e.kind(),
|
||||
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, Error>;
|
||||
|
||||
/// A failure on its way to a front-end: prose for the user, plus the
|
||||
|
||||
@@ -93,6 +93,13 @@ scale.browser-status-meter trough { min-width: 104px; min-height: 6px; }
|
||||
/* Shared with Me: what a share lets me do, as a tinted pill beside the name. */
|
||||
.role-pill { padding: 2px 10px; border-radius: 999px; font-weight: 600; color: var(--accent-color); background-color: color-mix(in srgb, var(--accent-bg-color) 16%, transparent); }
|
||||
|
||||
/* Activity: the feed sits on the window background as one card per row, under
|
||||
* day headings, like a boxed list with its rows set apart. */
|
||||
listview.activity-feed { background: none; color: inherit; padding: 0 12px 18px 12px; }
|
||||
listview.activity-feed > row { padding: 0; margin-bottom: 6px; background: none; }
|
||||
listview.activity-feed > header { padding: 18px 6px 6px 6px; background: none; }
|
||||
listview.activity-feed row.card { border-radius: 12px; }
|
||||
|
||||
/* Photos: how many timeline filters are on, as a small badge on the Filters button. */
|
||||
.filter-count { min-width: 18px; padding: 0 5px; border-radius: 999px; font-size: 0.8em; font-weight: 700; color: var(--accent-fg-color); background-color: var(--accent-bg-color); }
|
||||
/* The Places map: sea and land come in the map's own colours; markers (drawn
|
||||
|
||||
@@ -4,6 +4,7 @@ pub(crate) mod dates;
|
||||
#[path = "../i18n.rs"]
|
||||
pub(crate) mod i18n;
|
||||
pub(crate) mod pages;
|
||||
pub(crate) mod reload;
|
||||
pub(crate) mod widgets;
|
||||
|
||||
use pages::activity::*;
|
||||
@@ -23,6 +24,7 @@ use pages::status::*;
|
||||
use pages::takeout::*;
|
||||
use pages::trash::*;
|
||||
use pages::verify::*;
|
||||
use reload::{LoadTicket, Loader, replace_items};
|
||||
use widgets::details::*;
|
||||
use widgets::menu::*;
|
||||
use widgets::share_dialog::*;
|
||||
@@ -584,7 +586,8 @@ fn build_window(app: &adw::Application) {
|
||||
thumbnail_build_running: Cell::new(false),
|
||||
thumbnail_cancel_pending: Cell::new(false),
|
||||
search_source: RefCell::new(None),
|
||||
load_generation: Cell::new(0),
|
||||
loader: Loader::new(&browser_widgets.content),
|
||||
listed: RefCell::new(None),
|
||||
views: browser_widgets.views.clone(),
|
||||
column_view: browser_widgets.column_view.clone(),
|
||||
bulk: browser_widgets.bulk.clone(),
|
||||
@@ -615,6 +618,7 @@ fn build_window(app: &adw::Application) {
|
||||
selection: trash_widgets.selection.clone(),
|
||||
selection_bar: trash_widgets.selection_bar.clone(),
|
||||
selection_label: trash_widgets.selection_label.clone(),
|
||||
loader: Loader::new(&trash_widgets.content),
|
||||
},
|
||||
gallery: GalleryState {
|
||||
model: gallery_widgets.model.clone(),
|
||||
@@ -716,7 +720,8 @@ fn build_window(app: &adw::Application) {
|
||||
add_bookmark: shared_widgets.add_bookmark.clone(),
|
||||
nav: RefCell::new(Vec::new()),
|
||||
rows: RefCell::new(Vec::new()),
|
||||
inflight: Cell::new(false),
|
||||
loader: Loader::new(&shared_widgets.content),
|
||||
listed: RefCell::new(None),
|
||||
loaded_at: Cell::new(None),
|
||||
},
|
||||
shared_by_me: SharedByMeState {
|
||||
@@ -725,7 +730,7 @@ fn build_window(app: &adw::Application) {
|
||||
retry: shared_by_me_widgets.retry.clone(),
|
||||
group: shared_by_me_widgets.group.clone(),
|
||||
rows: RefCell::new(Vec::new()),
|
||||
inflight: Cell::new(false),
|
||||
loader: Loader::new(&shared_by_me_widgets.content),
|
||||
loaded_at: Cell::new(None),
|
||||
},
|
||||
devices: DevicesState {
|
||||
@@ -738,7 +743,7 @@ fn build_window(app: &adw::Application) {
|
||||
sync_rows: RefCell::new(Vec::new()),
|
||||
rename_this: devices_widgets.rename_this.clone(),
|
||||
this_device: RefCell::new(None),
|
||||
inflight: Cell::new(false),
|
||||
loader: Loader::new(&devices_widgets.content),
|
||||
loaded_at: Cell::new(None),
|
||||
},
|
||||
locations: LocationsState {
|
||||
@@ -748,6 +753,7 @@ fn build_window(app: &adw::Application) {
|
||||
group: locations_widgets.group.clone(),
|
||||
rows: RefCell::new(Vec::new()),
|
||||
inflight: Cell::new(false),
|
||||
loader: Loader::new(&locations_widgets.content),
|
||||
loaded_at: Cell::new(None),
|
||||
card: SyncCard {
|
||||
icon: locations_widgets.card_icon.clone(),
|
||||
@@ -774,11 +780,10 @@ fn build_window(app: &adw::Application) {
|
||||
content: activity_widgets.content.clone(),
|
||||
status: activity_widgets.status.clone(),
|
||||
retry: activity_widgets.retry.clone(),
|
||||
attention: activity_widgets.attention.clone(),
|
||||
attention_rows: RefCell::new(Vec::new()),
|
||||
days: activity_widgets.days.clone(),
|
||||
model: activity_widgets.model.clone(),
|
||||
feed: activity_widgets.feed.clone(),
|
||||
inflight: Cell::new(false),
|
||||
key: RefCell::new(None),
|
||||
loader: Loader::new(&activity_widgets.content),
|
||||
items: RefCell::new(Vec::new()),
|
||||
filter: Cell::new(ActivityFilter::All),
|
||||
conflicts: RefCell::new(None),
|
||||
@@ -843,7 +848,7 @@ fn build_window(app: &adw::Application) {
|
||||
wire_shared_by_me(&ui, &shared_by_me_widgets.retry);
|
||||
wire_devices(&ui, &devices_widgets.retry);
|
||||
wire_locations(&ui, &locations_widgets.retry, &locations_widgets.add_folder);
|
||||
wire_activity(&ui, &activity_widgets.retry, &activity_widgets.filters);
|
||||
wire_activity(&ui, &activity_widgets);
|
||||
wire_takeout(&ui, &takeout_widgets);
|
||||
wire_refresh(
|
||||
&ui,
|
||||
@@ -1795,10 +1800,10 @@ fn notify(id: &str, title: &str, body: &str) {
|
||||
fn spawn_request(
|
||||
socket: PathBuf,
|
||||
req: Request,
|
||||
) -> async_channel::Receiver<Result<Response, String>> {
|
||||
) -> async_channel::Receiver<Result<Response, pdfs_core::Error>> {
|
||||
let (tx, rx) = async_channel::bounded(1);
|
||||
std::thread::spawn(move || {
|
||||
let result = send(&socket, &req).map_err(|e| e.to_string());
|
||||
let result = send(&socket, &req);
|
||||
let _ = tx.send_blocking(result);
|
||||
});
|
||||
rx
|
||||
|
||||
@@ -2,19 +2,21 @@ use crate::*;
|
||||
|
||||
pub(crate) struct ActivityState {
|
||||
// Activity page: a "Needs attention" section for live conflicts, then the
|
||||
// feed grouped by day, rebuilt wholesale on each change.
|
||||
// feed grouped by day, in one list that a repaint updates in place.
|
||||
pub(crate) content: gtk4::Stack,
|
||||
pub(crate) status: adw::StatusPage,
|
||||
pub(crate) retry: gtk4::Button,
|
||||
pub(crate) attention: adw::PreferencesGroup,
|
||||
pub(crate) attention_rows: RefCell<Vec<adw::ActionRow>>,
|
||||
pub(crate) days: gtk4::Box,
|
||||
/// The rows, as [`FeedItem`]s. A repaint swaps only the ones that
|
||||
/// changed, so the page polling every couple of seconds keeps the user's
|
||||
/// scroll position.
|
||||
pub(crate) model: gio::ListStore,
|
||||
/// The list, or the "No matching activity" status when the filter leaves
|
||||
/// nothing.
|
||||
pub(crate) feed: gtk4::Stack,
|
||||
pub(crate) inflight: Cell<bool>,
|
||||
/// Fingerprint of what is on screen (see [`activity_key`]). The page polls
|
||||
/// every couple of seconds and usually gets back exactly what it is already
|
||||
/// showing; rebuilding every row for that would throw away the user's
|
||||
/// scroll position several times a minute.
|
||||
pub(crate) key: RefCell<Option<String>>,
|
||||
/// Shows the loading state if a full load is slow; the rows stay up
|
||||
/// until then.
|
||||
pub(crate) loader: Rc<Loader>,
|
||||
/// The last feed the daemon sent, so a filter change repaints without a
|
||||
/// round-trip.
|
||||
pub(crate) items: RefCell<Vec<ActivityEntry>>,
|
||||
@@ -30,8 +32,9 @@ pub(crate) struct ActivityState {
|
||||
pub(crate) struct ActivityWidgets {
|
||||
pub(crate) content: gtk4::Stack,
|
||||
pub(crate) status: adw::StatusPage,
|
||||
pub(crate) attention: adw::PreferencesGroup,
|
||||
pub(crate) days: gtk4::Box,
|
||||
pub(crate) model: gio::ListStore,
|
||||
pub(crate) list: gtk4::ListView,
|
||||
pub(crate) feed: gtk4::Stack,
|
||||
pub(crate) filters: Vec<(ActivityFilter, gtk4::ToggleButton)>,
|
||||
pub(crate) retry: gtk4::Button,
|
||||
pub(crate) refresh: gtk4::Button,
|
||||
@@ -89,7 +92,8 @@ impl ActivityFilter {
|
||||
pub(crate) fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) {
|
||||
let refresh = refresh_button();
|
||||
|
||||
// Filter chips: a linked row of radio toggles.
|
||||
// Filter chips: a linked row of radio toggles. They sit above the list,
|
||||
// not in it, so they stay in reach however far the feed is scrolled.
|
||||
let bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
|
||||
bar.add_css_class("linked");
|
||||
bar.set_halign(gtk4::Align::Start);
|
||||
@@ -104,25 +108,40 @@ pub(crate) fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) {
|
||||
bar.append(&button);
|
||||
filters.push((filter, button));
|
||||
}
|
||||
|
||||
let attention = adw::PreferencesGroup::builder()
|
||||
.title(gettext("Needs attention"))
|
||||
.visible(false)
|
||||
.build();
|
||||
let days = gtk4::Box::new(gtk4::Orientation::Vertical, 24);
|
||||
|
||||
let column = gtk4::Box::new(gtk4::Orientation::Vertical, 24);
|
||||
column.set_margin_top(18);
|
||||
column.set_margin_bottom(18);
|
||||
column.set_margin_start(18);
|
||||
column.set_margin_end(18);
|
||||
column.append(&bar);
|
||||
column.append(&attention);
|
||||
column.append(&days);
|
||||
let clamp = adw::Clamp::builder()
|
||||
bar.set_margin_top(12);
|
||||
bar.set_margin_bottom(6);
|
||||
bar.set_margin_start(12);
|
||||
bar.set_margin_end(12);
|
||||
let bar_clamp = adw::Clamp::builder()
|
||||
.maximum_size(900)
|
||||
.tightening_threshold(600)
|
||||
.child(&column)
|
||||
.child(&bar)
|
||||
.build();
|
||||
|
||||
// Rows are sectioned by the key each item carries: the conflicts first,
|
||||
// then one section per day. The items already arrive in that order, and
|
||||
// the sort is stable, so this only marks where each section starts.
|
||||
let model = gio::ListStore::new::<BoxedAnyObject>();
|
||||
let sections = gtk4::SortListModel::new(Some(model.clone()), None::<gtk4::Sorter>);
|
||||
sections.set_section_sorter(Some(>k4::CustomSorter::new(|a, b| {
|
||||
let key = |object: &glib::Object| {
|
||||
object
|
||||
.downcast_ref::<BoxedAnyObject>()
|
||||
.map_or(i64::MIN, |item| item.borrow::<FeedItem>().section())
|
||||
};
|
||||
// Newest section first.
|
||||
key(b).cmp(&key(a)).into()
|
||||
})));
|
||||
let list = gtk4::ListView::builder()
|
||||
.model(>k4::NoSelection::new(Some(sections)))
|
||||
.single_click_activate(true)
|
||||
.build();
|
||||
list.add_css_class("activity-feed");
|
||||
list.set_header_factory(Some(&feed_header_factory()));
|
||||
let clamp = adw::ClampScrollable::builder()
|
||||
.maximum_size(900)
|
||||
.tightening_threshold(600)
|
||||
.child(&list)
|
||||
.build();
|
||||
let scroll = gtk4::ScrolledWindow::builder()
|
||||
.vexpand(true)
|
||||
@@ -130,6 +149,21 @@ pub(crate) fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) {
|
||||
.child(&clamp)
|
||||
.build();
|
||||
|
||||
let no_match = adw::StatusPage::builder()
|
||||
.icon_name("edit-find-symbolic")
|
||||
.title(gettext("No matching activity"))
|
||||
.description(gettext("Nothing in the recent log fits this filter."))
|
||||
.vexpand(true)
|
||||
.build();
|
||||
no_match.add_css_class("compact");
|
||||
let feed = gtk4::Stack::new();
|
||||
feed.add_named(&scroll, Some("rows"));
|
||||
feed.add_named(&no_match, Some("empty"));
|
||||
|
||||
let column = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
|
||||
column.append(&bar_clamp);
|
||||
column.append(&feed);
|
||||
|
||||
let retry = gtk4::Button::builder()
|
||||
.label(gettext("Retry"))
|
||||
.halign(gtk4::Align::Center)
|
||||
@@ -147,7 +181,7 @@ pub(crate) fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) {
|
||||
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(&column, Some("list"));
|
||||
content.add_named(&status, Some("status"));
|
||||
|
||||
let (frame, header, _) = page_frame(&gettext("Activity"), &content);
|
||||
@@ -158,8 +192,9 @@ pub(crate) fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) {
|
||||
ActivityWidgets {
|
||||
content,
|
||||
status,
|
||||
attention,
|
||||
days,
|
||||
model,
|
||||
list,
|
||||
feed,
|
||||
filters,
|
||||
retry,
|
||||
refresh,
|
||||
@@ -167,15 +202,62 @@ pub(crate) fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) {
|
||||
)
|
||||
}
|
||||
|
||||
/// Install the Activity page's retry button.
|
||||
pub(crate) fn wire_activity(
|
||||
ui: &Rc<Ui>,
|
||||
retry: >k4::Button,
|
||||
filters: &[(ActivityFilter, gtk4::ToggleButton)],
|
||||
) {
|
||||
/// Section headings: "Needs attention" with a line on what to do, or the day.
|
||||
/// Each is read from the first item of its section.
|
||||
fn feed_header_factory() -> gtk4::SignalListItemFactory {
|
||||
let factory = gtk4::SignalListItemFactory::new();
|
||||
factory.connect_setup(|_, header| {
|
||||
let header = header.downcast_ref::<gtk4::ListHeader>().unwrap();
|
||||
let title = gtk4::Label::builder().xalign(0.0).build();
|
||||
title.add_css_class("heading");
|
||||
let description = gtk4::Label::builder().xalign(0.0).wrap(true).build();
|
||||
description.add_css_class("dim-label");
|
||||
let column = gtk4::Box::new(gtk4::Orientation::Vertical, 6);
|
||||
column.add_css_class("activity-heading");
|
||||
column.append(&title);
|
||||
column.append(&description);
|
||||
header.set_child(Some(&column));
|
||||
});
|
||||
factory.connect_bind(|_, header| {
|
||||
let header = header.downcast_ref::<gtk4::ListHeader>().unwrap();
|
||||
let Some(column) = header.child() else {
|
||||
return;
|
||||
};
|
||||
let Some(title) = column.first_child().and_downcast::<gtk4::Label>() else {
|
||||
return;
|
||||
};
|
||||
let Some(description) = title.next_sibling().and_downcast::<gtk4::Label>() else {
|
||||
return;
|
||||
};
|
||||
let Some(item) = header.item().and_downcast::<BoxedAnyObject>() else {
|
||||
return;
|
||||
};
|
||||
let (heading, line) = match &*item.borrow::<FeedItem>() {
|
||||
FeedItem::Attention { open, .. } => (
|
||||
gettext("Needs attention"),
|
||||
Some(ngettext_f(
|
||||
"A file was changed in two places at once. Choose which version to keep.",
|
||||
"{n} files were changed in two places at once. Choose which versions to keep.",
|
||||
*open as u64,
|
||||
&[],
|
||||
)),
|
||||
),
|
||||
FeedItem::Logged { day_label, .. } => (day_label.clone(), None),
|
||||
};
|
||||
title.set_label(&heading);
|
||||
description.set_visible(line.is_some());
|
||||
description.set_label(line.as_deref().unwrap_or_default());
|
||||
});
|
||||
factory
|
||||
}
|
||||
|
||||
/// Install the Activity page's retry button, filters and rows.
|
||||
pub(crate) fn wire_activity(ui: &Rc<Ui>, widgets: &ActivityWidgets) {
|
||||
let ui_retry = ui.clone();
|
||||
retry.connect_clicked(move |_| restart_service_then(&ui_retry, load_activity));
|
||||
for (filter, button) in filters {
|
||||
widgets
|
||||
.retry
|
||||
.connect_clicked(move |_| restart_service_then(&ui_retry, load_activity));
|
||||
for (filter, button) in &widgets.filters {
|
||||
let ui = ui.clone();
|
||||
let filter = *filter;
|
||||
button.connect_toggled(move |button| {
|
||||
@@ -185,6 +267,56 @@ pub(crate) fn wire_activity(
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let factory = gtk4::SignalListItemFactory::new();
|
||||
factory.connect_bind({
|
||||
let ui = ui.clone();
|
||||
move |_, item| {
|
||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||
let Some(object) = item.item().and_downcast::<BoxedAnyObject>() else {
|
||||
return;
|
||||
};
|
||||
let row = match &*object.borrow::<FeedItem>() {
|
||||
FeedItem::Attention { conflict, .. } => {
|
||||
item.set_activatable(true);
|
||||
conflict_row(&ui, conflict)
|
||||
}
|
||||
FeedItem::Logged {
|
||||
entry,
|
||||
count,
|
||||
first,
|
||||
pending,
|
||||
resolved,
|
||||
..
|
||||
} => {
|
||||
item.set_activatable(false);
|
||||
feed_row(&ui, entry, *count, *first, pending.as_ref(), *resolved)
|
||||
}
|
||||
};
|
||||
row.add_css_class("card");
|
||||
item.set_child(Some(&row));
|
||||
}
|
||||
});
|
||||
factory.connect_unbind(|_, item| {
|
||||
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
|
||||
item.set_child(None::<>k4::Widget>);
|
||||
});
|
||||
widgets.list.set_factory(Some(&factory));
|
||||
// A list row does not emit the action row's own `activated`, so the
|
||||
// conflicts open their dialog from here.
|
||||
let ui = ui.clone();
|
||||
widgets.list.connect_activate(move |list, position| {
|
||||
let Some(object) = list
|
||||
.model()
|
||||
.and_then(|sections| sections.item(position))
|
||||
.and_downcast::<BoxedAnyObject>()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if let FeedItem::Attention { conflict, .. } = &*object.borrow::<FeedItem>() {
|
||||
prompt_resolve_conflict(&ui, conflict);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Show a status page in place of the Activity list.
|
||||
@@ -200,9 +332,6 @@ pub(crate) fn activity_status(
|
||||
ui.activity.status.set_description(Some(description));
|
||||
ui.activity.retry.set_visible(retry);
|
||||
ui.activity.content.set_visible_child_name("status");
|
||||
// The list is no longer what is on screen, so the next repaint must not skip
|
||||
// itself as a no-op and leave this status view up.
|
||||
*ui.activity.key.borrow_mut() = None;
|
||||
}
|
||||
|
||||
/// Refresh the Activity feed in place, with no status flash and no spinner.
|
||||
@@ -239,13 +368,16 @@ pub(crate) fn load_activity(ui: &Rc<Ui>) {
|
||||
return;
|
||||
}
|
||||
ui.activity.inflight.set(true);
|
||||
activity_status(
|
||||
ui,
|
||||
"document-open-recent-symbolic",
|
||||
&gettext("Loading…"),
|
||||
&gettext("Reading recent activity."),
|
||||
false,
|
||||
);
|
||||
let ui_p = ui.clone();
|
||||
let ticket = ui.activity.loader.refresh(move || {
|
||||
activity_status(
|
||||
&ui_p,
|
||||
"document-open-recent-symbolic",
|
||||
&gettext("Loading…"),
|
||||
&gettext("Reading recent activity."),
|
||||
false,
|
||||
);
|
||||
});
|
||||
ui.busy_begin();
|
||||
let rx = spawn_request(
|
||||
ui.dirs.control_socket(),
|
||||
@@ -256,6 +388,7 @@ pub(crate) fn load_activity(ui: &Rc<Ui>) {
|
||||
let result = rx.recv().await;
|
||||
ui.busy_end();
|
||||
ui.activity.inflight.set(false);
|
||||
drop(ticket);
|
||||
match result {
|
||||
Ok(Ok(Response::Activity { items })) => repaint_activity(&ui, &items),
|
||||
Ok(Ok(Response::Error { message, .. })) => activity_status(
|
||||
@@ -318,33 +451,49 @@ pub(crate) fn refresh_activity_conflicts(ui: &Rc<Ui>, force: bool) {
|
||||
});
|
||||
}
|
||||
|
||||
/// A cheap fingerprint of what the page shows: the feed's length and newest
|
||||
/// entry (the log is append-only and newest-first, so that pins the feed), the
|
||||
/// filter, and which conflicts are still open.
|
||||
pub(crate) fn activity_key(
|
||||
items: &[ActivityEntry],
|
||||
filter: ActivityFilter,
|
||||
conflicts: Option<&[ConflictInfo]>,
|
||||
) -> String {
|
||||
let newest = match items.first() {
|
||||
Some(a) => format!("{}:{}:{}:{}", items.len(), a.time, a.target, a.detail),
|
||||
None => String::new(),
|
||||
};
|
||||
let open = conflicts.map(|c| {
|
||||
c.iter()
|
||||
.map(|c| c.path.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
});
|
||||
format!("{newest}|{filter:?}|{open:?}")
|
||||
}
|
||||
|
||||
/// Keep a fresh log from the daemon and repaint with it.
|
||||
pub(crate) fn repaint_activity(ui: &Rc<Ui>, items: &[ActivityEntry]) {
|
||||
*ui.activity.items.borrow_mut() = items.to_vec();
|
||||
paint_activity(ui);
|
||||
}
|
||||
|
||||
/// One row of the Activity list, with everything its row and its section
|
||||
/// heading show, so that comparing two says whether the row must be redrawn.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum FeedItem {
|
||||
/// A conflict still waiting for a decision. `open` counts all of them,
|
||||
/// for the section heading.
|
||||
Attention { conflict: ConflictInfo, open: usize },
|
||||
/// A logged activity, folded together with its repeats.
|
||||
Logged {
|
||||
entry: ActivityEntry,
|
||||
/// How many times it was logged in a row (or, for a conflict, at all).
|
||||
count: usize,
|
||||
/// When the oldest collapsed repeat happened.
|
||||
first: i64,
|
||||
/// The local day it happened on as `yyyymmdd`, which picks its
|
||||
/// section, and that day's heading.
|
||||
day: i64,
|
||||
day_label: String,
|
||||
/// The open conflict a logged conflict names, which the row offers
|
||||
/// to resolve.
|
||||
pending: Option<ConflictInfo>,
|
||||
/// A logged conflict that is no longer open.
|
||||
resolved: bool,
|
||||
},
|
||||
}
|
||||
|
||||
impl FeedItem {
|
||||
/// The section the item belongs to. Sections run from the highest key
|
||||
/// down: the conflicts, then the days, newest first.
|
||||
fn section(&self) -> i64 {
|
||||
match self {
|
||||
FeedItem::Attention { .. } => i64::MAX,
|
||||
FeedItem::Logged { day, .. } => *day,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One feed row: an entry and how many times it was logged in a row (or, for
|
||||
/// a conflict, at all).
|
||||
#[derive(Debug)]
|
||||
@@ -410,18 +559,66 @@ pub(crate) fn day_label(at: &glib::DateTime, now: &glib::DateTime) -> String {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Rebuild the page from the kept feed and conflict list, unless it already
|
||||
/// shows exactly this.
|
||||
/// The rows of the page: the open conflicts, then the feed entries the filter
|
||||
/// lets through, each with its day. `conflicts` is `None` while the open
|
||||
/// conflicts are not known yet.
|
||||
pub(crate) fn feed_items(
|
||||
items: &[ActivityEntry],
|
||||
filter: ActivityFilter,
|
||||
conflicts: Option<&[ConflictInfo]>,
|
||||
now: Option<&glib::DateTime>,
|
||||
) -> Vec<FeedItem> {
|
||||
let open = conflicts.unwrap_or_default();
|
||||
let mut rows: Vec<FeedItem> = open
|
||||
.iter()
|
||||
.map(|conflict| FeedItem::Attention {
|
||||
conflict: conflict.clone(),
|
||||
open: open.len(),
|
||||
})
|
||||
.collect();
|
||||
for row in collapse_feed(items) {
|
||||
if !filter.matches(row.entry) {
|
||||
continue;
|
||||
}
|
||||
let a = row.entry;
|
||||
let at = glib::DateTime::from_unix_local(a.time).ok();
|
||||
let (day, day_label) = match (&at, now) {
|
||||
(Some(at), Some(now)) => {
|
||||
let (y, m, d) = at.ymd();
|
||||
(
|
||||
i64::from(y) * 10_000 + i64::from(m) * 100 + i64::from(d),
|
||||
day_label(at, now),
|
||||
)
|
||||
}
|
||||
_ => (0, String::new()),
|
||||
};
|
||||
// A logged conflict names the copy; it is still open while the daemon
|
||||
// still lists a copy by that name.
|
||||
let pending = (a.kind == ActivityKind::Conflict)
|
||||
.then(|| open.iter().find(|c| file_name(&c.path) == a.target))
|
||||
.flatten()
|
||||
.cloned();
|
||||
let resolved = a.kind == ActivityKind::Conflict && pending.is_none() && conflicts.is_some();
|
||||
rows.push(FeedItem::Logged {
|
||||
entry: a.clone(),
|
||||
count: row.count,
|
||||
first: row.first,
|
||||
day,
|
||||
day_label,
|
||||
pending,
|
||||
resolved,
|
||||
});
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
/// Bring the page up to date with the kept feed and conflict list, changing
|
||||
/// only the rows that differ.
|
||||
pub(crate) fn paint_activity(ui: &Rc<Ui>) {
|
||||
let state = &ui.activity;
|
||||
let items = state.items.borrow();
|
||||
let conflicts = state.conflicts.borrow();
|
||||
let filter = state.filter.get();
|
||||
let key = activity_key(&items, filter, conflicts.as_deref());
|
||||
if state.key.borrow().as_deref() == Some(key.as_str()) {
|
||||
return;
|
||||
}
|
||||
let open: &[ConflictInfo] = conflicts.as_deref().unwrap_or_default();
|
||||
let open = conflicts.as_deref().unwrap_or_default();
|
||||
if items.is_empty() && open.is_empty() {
|
||||
activity_status(
|
||||
ui,
|
||||
@@ -430,95 +627,45 @@ pub(crate) fn paint_activity(ui: &Rc<Ui>) {
|
||||
&gettext("Uploads, moves, shares and other changes appear here as they happen."),
|
||||
false,
|
||||
);
|
||||
return;
|
||||
}
|
||||
*state.key.borrow_mut() = Some(key);
|
||||
state.content.set_visible_child_name("list");
|
||||
|
||||
// Needs attention: the live conflicts, each opening the resolve dialog.
|
||||
for row in state.attention_rows.borrow_mut().drain(..) {
|
||||
state.attention.remove(&row);
|
||||
}
|
||||
state.attention.set_visible(!open.is_empty());
|
||||
state.attention.set_description(Some(&ngettext_f(
|
||||
"A file was changed in two places at once. Choose which version to keep.",
|
||||
"{n} files were changed in two places at once. Choose which versions to keep.",
|
||||
open.len() as u64,
|
||||
&[],
|
||||
)));
|
||||
let mut attention_rows = state.attention_rows.borrow_mut();
|
||||
for conflict in open {
|
||||
let row = conflict_row(ui, conflict);
|
||||
state.attention.add(&row);
|
||||
attention_rows.push(row);
|
||||
}
|
||||
drop(attention_rows);
|
||||
|
||||
while let Some(child) = state.days.first_child() {
|
||||
state.days.remove(&child);
|
||||
}
|
||||
let rows: Vec<FeedRow> = collapse_feed(&items)
|
||||
.into_iter()
|
||||
.filter(|row| filter.matches(row.entry))
|
||||
.collect();
|
||||
if rows.is_empty() {
|
||||
let empty = adw::StatusPage::builder()
|
||||
.icon_name("edit-find-symbolic")
|
||||
.title(gettext("No matching activity"))
|
||||
.description(gettext("Nothing in the recent log fits this filter."))
|
||||
.build();
|
||||
empty.add_css_class("compact");
|
||||
state.days.append(&empty);
|
||||
state.model.remove_all();
|
||||
return;
|
||||
}
|
||||
let now = glib::DateTime::now_local().ok();
|
||||
let mut group: Option<(String, adw::PreferencesGroup)> = None;
|
||||
for row in rows {
|
||||
let at = glib::DateTime::from_unix_local(row.entry.time).ok();
|
||||
let label = match (&at, &now) {
|
||||
(Some(at), Some(now)) => day_label(at, now),
|
||||
_ => String::new(),
|
||||
};
|
||||
if group.as_ref().is_none_or(|(day, _)| *day != label) {
|
||||
let section = adw::PreferencesGroup::builder().title(&label).build();
|
||||
state.days.append(§ion);
|
||||
group = Some((label, section));
|
||||
}
|
||||
if let Some((_, section)) = &group {
|
||||
section.add(&feed_row(ui, &row, at.as_ref(), open));
|
||||
}
|
||||
}
|
||||
let rows = feed_items(
|
||||
&items,
|
||||
state.filter.get(),
|
||||
conflicts.as_deref(),
|
||||
now.as_ref(),
|
||||
);
|
||||
replace_items(&state.model, &rows);
|
||||
state
|
||||
.feed
|
||||
.set_visible_child_name(if rows.is_empty() { "empty" } else { "rows" });
|
||||
state.content.set_visible_child_name("list");
|
||||
}
|
||||
|
||||
/// One row of the feed.
|
||||
/// One logged row of the feed, with the fields of [`FeedItem::Logged`].
|
||||
fn feed_row(
|
||||
ui: &Rc<Ui>,
|
||||
row: &FeedRow,
|
||||
at: Option<&glib::DateTime>,
|
||||
open: &[ConflictInfo],
|
||||
a: &ActivityEntry,
|
||||
count: usize,
|
||||
first: i64,
|
||||
pending: Option<&ConflictInfo>,
|
||||
resolved: bool,
|
||||
) -> adw::ActionRow {
|
||||
let a = row.entry;
|
||||
let title = activity_title(a.kind, &a.target);
|
||||
// A logged conflict names the copy; it is still open while the daemon
|
||||
// still lists a copy by that name.
|
||||
let pending = (a.kind == ActivityKind::Conflict)
|
||||
.then(|| open.iter().find(|c| file_name(&c.path) == a.target))
|
||||
.flatten();
|
||||
let resolved = a.kind == ActivityKind::Conflict
|
||||
&& pending.is_none()
|
||||
&& ui.activity.conflicts.borrow().is_some();
|
||||
|
||||
let mut subtitle: Vec<String> = Vec::new();
|
||||
if !a.detail.is_empty() {
|
||||
subtitle.push(capitalize(&a.detail));
|
||||
}
|
||||
if row.count > 1 {
|
||||
let since = dates::relative(row.first);
|
||||
if count > 1 {
|
||||
let since = dates::relative(first);
|
||||
// Translators: {time} is a date or time such as "Sep 21" or "5 min ago".
|
||||
subtitle.push(ngettext_f(
|
||||
"{n} time since {time}",
|
||||
"{n} times since {time}",
|
||||
row.count as u64,
|
||||
count as u64,
|
||||
&[("time", &since)],
|
||||
));
|
||||
}
|
||||
@@ -554,7 +701,9 @@ fn feed_row(
|
||||
// Translators: strftime format for the time of day in the activity feed, such as "14:05".
|
||||
let time_format = gettext("%H:%M");
|
||||
let time = gtk4::Label::new(Some(
|
||||
&at.and_then(|at| at.format(&time_format).ok())
|
||||
&glib::DateTime::from_unix_local(a.time)
|
||||
.ok()
|
||||
.and_then(|at| at.format(&time_format).ok())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_default(),
|
||||
));
|
||||
@@ -703,6 +852,81 @@ mod tests {
|
||||
assert!(!ActivityFilter::Sharing.matches(&fine));
|
||||
}
|
||||
|
||||
fn conflict(path: &str) -> ConflictInfo {
|
||||
ConflictInfo {
|
||||
path: path.into(),
|
||||
original_path: "docs/a.txt".into(),
|
||||
original_exists: true,
|
||||
size: 1,
|
||||
modified: 0,
|
||||
original_size: None,
|
||||
original_modified: None,
|
||||
identical: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_conflicts_lead_and_logged_ones_know_their_state() {
|
||||
let items = vec![
|
||||
entry(
|
||||
300,
|
||||
ActivityKind::Conflict,
|
||||
"a (sync-conflict).txt",
|
||||
"",
|
||||
false,
|
||||
),
|
||||
entry(
|
||||
200,
|
||||
ActivityKind::Conflict,
|
||||
"b (sync-conflict).txt",
|
||||
"",
|
||||
false,
|
||||
),
|
||||
entry(100, ActivityKind::Upload, "c.txt", "", true),
|
||||
];
|
||||
let open = [conflict("docs/a (sync-conflict).txt")];
|
||||
let rows = feed_items(&items, ActivityFilter::All, Some(&open), None);
|
||||
assert_eq!(rows.len(), 4);
|
||||
assert!(matches!(&rows[0], FeedItem::Attention { open: 1, .. }));
|
||||
assert!(rows[0].section() > rows[1].section());
|
||||
let states: Vec<(bool, bool)> = rows[1..]
|
||||
.iter()
|
||||
.map(|row| match row {
|
||||
FeedItem::Logged {
|
||||
pending, resolved, ..
|
||||
} => (pending.is_some(), *resolved),
|
||||
FeedItem::Attention { .. } => unreachable!(),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(states, vec![(true, false), (false, true), (false, false)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_conflicts_resolve_nothing_and_filters_spare_the_attention_rows() {
|
||||
let items = vec![
|
||||
entry(
|
||||
300,
|
||||
ActivityKind::Conflict,
|
||||
"a (sync-conflict).txt",
|
||||
"",
|
||||
false,
|
||||
),
|
||||
entry(100, ActivityKind::Upload, "c.txt", "", true),
|
||||
];
|
||||
let rows = feed_items(&items, ActivityFilter::All, None, None);
|
||||
assert!(matches!(
|
||||
&rows[0],
|
||||
FeedItem::Logged {
|
||||
resolved: false,
|
||||
..
|
||||
}
|
||||
));
|
||||
let open = [conflict("docs/a (sync-conflict).txt")];
|
||||
let rows = feed_items(&items, ActivityFilter::Sharing, Some(&open), None);
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert!(matches!(&rows[0], FeedItem::Attention { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn days_read_today_yesterday_weekday_then_date() {
|
||||
let tz = glib::TimeZone::utc();
|
||||
|
||||
@@ -52,10 +52,14 @@ pub(crate) struct BrowserState {
|
||||
/// Pending debounce timer for the search box; replaced on every keystroke so
|
||||
/// only the last pause actually fires a [`Request::Search`].
|
||||
pub(crate) search_source: RefCell<Option<glib::SourceId>>,
|
||||
/// Identity of the newest folder/search request. Paths and queries can be
|
||||
/// requested repeatedly, so comparing their text alone cannot reject an
|
||||
/// older response that finishes after a manual refresh.
|
||||
pub(crate) load_generation: Cell<u64>,
|
||||
/// Runs folder loads and searches. Paths and queries can be requested
|
||||
/// repeatedly, so only its tickets can tell an older reply that finishes
|
||||
/// after a manual refresh from the newest one.
|
||||
pub(crate) loader: Rc<Loader>,
|
||||
/// The folder the model holds rows for, or `None` while it holds search
|
||||
/// hits or nothing. Reloading this folder keeps the rows up and usable;
|
||||
/// loading any other greys them out until the new ones arrive.
|
||||
pub(crate) listed: RefCell<Option<String>>,
|
||||
/// The grid/list view stack, read to find out which view is on screen.
|
||||
pub(crate) views: gtk4::Stack,
|
||||
/// The list view, whose Location column shows only for search hits.
|
||||
@@ -2815,24 +2819,33 @@ pub(crate) fn icon_base_for(entry: &DirEntry) -> &'static str {
|
||||
/// Request the current browser directory from the daemon and repaint both views.
|
||||
pub(crate) fn load_browser(ui: &Rc<Ui>) {
|
||||
cancel_file_thumbnails(ui);
|
||||
let generation = ui.browser.load_generation.get().wrapping_add(1);
|
||||
ui.browser.load_generation.set(generation);
|
||||
let path = ui.browser.path.borrow().clone();
|
||||
repaint_crumb(ui, &path);
|
||||
sync_history_actions(ui);
|
||||
sync_search_scope(ui);
|
||||
ui.browser.summary.set_label(&gettext("Loading…"));
|
||||
|
||||
// Drop the previous folder's rows up front: a slow reply must not leave stale
|
||||
// entries visible, where clicking one would open with a wrong relative path.
|
||||
ui.browser.model.remove_all();
|
||||
browser_status(
|
||||
ui,
|
||||
"folder-symbolic",
|
||||
&gettext("Loading…"),
|
||||
&gettext("Reading this folder."),
|
||||
false,
|
||||
);
|
||||
// A reload of the folder on screen leaves its rows alone. Any other load
|
||||
// keeps the old rows up only greyed out, with nothing selected: their
|
||||
// paths resolve against the new folder, so acting on one would hit the
|
||||
// wrong file.
|
||||
let ui_p = ui.clone();
|
||||
let placeholder = move || {
|
||||
ui_p.browser.model.remove_all();
|
||||
ui_p.browser.summary.set_label(&gettext("Loading…"));
|
||||
browser_status(
|
||||
&ui_p,
|
||||
"folder-symbolic",
|
||||
&gettext("Loading…"),
|
||||
&gettext("Reading this folder."),
|
||||
false,
|
||||
);
|
||||
};
|
||||
let ticket = if ui.browser.listed.borrow().as_deref() == Some(path.as_str()) {
|
||||
ui.browser.loader.refresh(placeholder)
|
||||
} else {
|
||||
forget_listing(ui);
|
||||
ui.browser.loader.replace(placeholder)
|
||||
};
|
||||
|
||||
ui.busy_begin();
|
||||
let rx = spawn_request(
|
||||
@@ -2846,7 +2859,7 @@ pub(crate) fn load_browser(ui: &Rc<Ui>) {
|
||||
// The user may have navigated on while this folder was loading. A stale
|
||||
// out-of-order reply must not repaint rows for a folder we've left, or
|
||||
// the breadcrumb and the grid would disagree.
|
||||
if ui.browser.load_generation.get() != generation || *ui.browser.path.borrow() != path {
|
||||
if !ticket.is_current() || *ui.browser.path.borrow() != path {
|
||||
return;
|
||||
}
|
||||
match result {
|
||||
@@ -2866,6 +2879,7 @@ pub(crate) fn load_browser(ui: &Rc<Ui>) {
|
||||
/// in-band failures (a bad path, a permission error) — the mount is up, so Retry
|
||||
/// (which restarts the service) wouldn't help and isn't offered.
|
||||
pub(crate) fn browser_failed(ui: &Rc<Ui>, message: &str, kind: ErrorKind) {
|
||||
forget_listing(ui);
|
||||
ui.browser.model.remove_all();
|
||||
ui.browser.summary.set_label(&gettext("Folder unavailable"));
|
||||
browser_status(
|
||||
@@ -2899,7 +2913,7 @@ pub(crate) fn browser_unreachable(ui: &Rc<Ui>) {
|
||||
pub(crate) fn repaint_browser(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||
show_location_column(ui, false);
|
||||
*ui.browser.listing.borrow_mut() = entries.to_vec();
|
||||
ui.browser.model.remove_all();
|
||||
*ui.browser.listed.borrow_mut() = Some(ui.browser.path.borrow().clone());
|
||||
ui.browser.summary.set_label(&listing_summary(
|
||||
entries.iter().filter(|entry| !entry.is_dir).count(),
|
||||
entries.iter().filter(|entry| entry.is_dir).count(),
|
||||
@@ -2917,6 +2931,7 @@ pub(crate) fn repaint_browser(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||
&gettext("Drop files here, or upload a file or create a folder to get started."),
|
||||
false,
|
||||
);
|
||||
ui.browser.model.remove_all();
|
||||
ui.browser.empty_actions.set_visible(true);
|
||||
return;
|
||||
}
|
||||
@@ -2924,9 +2939,17 @@ pub(crate) fn repaint_browser(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||
|
||||
let mut sorted = entries.to_vec();
|
||||
sort_entries(&mut sorted, ui.browser.view.get());
|
||||
for entry in sorted {
|
||||
ui.browser.model.append(&BoxedAnyObject::new(entry));
|
||||
}
|
||||
// Only the rows that changed are swapped, so a refresh keeps the
|
||||
// selection, the scroll position and the thumbnails already drawn.
|
||||
replace_items(&ui.browser.model, &sorted);
|
||||
}
|
||||
|
||||
/// Forget which folder the rows belong to and drop the selection, before the
|
||||
/// rows stop matching the folder on screen.
|
||||
fn forget_listing(ui: &Rc<Ui>) {
|
||||
ui.browser.listed.borrow_mut().take();
|
||||
active_selection(ui).unselect_all();
|
||||
clear_details(ui);
|
||||
}
|
||||
|
||||
/// Wire the browser header's search box: debounce keystrokes, then either run a
|
||||
@@ -2984,17 +3007,22 @@ fn search_scope(ui: &Rc<Ui>) -> Option<String> {
|
||||
/// (each hit carries its full path; see [`entry_rel`]).
|
||||
pub(crate) fn run_search(ui: &Rc<Ui>, query: &str) {
|
||||
cancel_file_thumbnails(ui);
|
||||
let generation = ui.browser.load_generation.get().wrapping_add(1);
|
||||
ui.browser.load_generation.set(generation);
|
||||
ui.browser.model.remove_all();
|
||||
ui.browser.summary.set_label(&gettext("Searching…"));
|
||||
browser_status(
|
||||
ui,
|
||||
"system-search-symbolic",
|
||||
&gettext("Searching…"),
|
||||
&gettext_f("Looking for “{query}”.", &[("query", query)]),
|
||||
false,
|
||||
);
|
||||
// Hits carry their own paths, but the old rows may not: grey them out and
|
||||
// drop the selection until the hits arrive.
|
||||
forget_listing(ui);
|
||||
let ui_p = ui.clone();
|
||||
let looking_for = query.to_string();
|
||||
let ticket = ui.browser.loader.replace(move || {
|
||||
ui_p.browser.model.remove_all();
|
||||
ui_p.browser.summary.set_label(&gettext("Searching…"));
|
||||
browser_status(
|
||||
&ui_p,
|
||||
"system-search-symbolic",
|
||||
&gettext("Searching…"),
|
||||
&gettext_f("Looking for “{query}”.", &[("query", &looking_for)]),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
ui.busy_begin();
|
||||
let query = query.to_string();
|
||||
@@ -3013,9 +3041,7 @@ pub(crate) fn run_search(ui: &Rc<Ui>, query: &str) {
|
||||
// The box may have been cleared or typed past while the reply was in
|
||||
// flight; if the query no longer matches, a fresher load/search already
|
||||
// owns the model — drop this stale, possibly out-of-order result.
|
||||
if ui.browser.load_generation.get() != generation
|
||||
|| ui.browser.search.text().trim() != query
|
||||
{
|
||||
if !ticket.is_current() || ui.browser.search.text().trim() != query {
|
||||
return;
|
||||
}
|
||||
match result {
|
||||
@@ -3036,7 +3062,6 @@ pub(crate) fn run_search(ui: &Rc<Ui>, query: &str) {
|
||||
/// handlers already understand.
|
||||
pub(crate) fn repaint_search(ui: &Rc<Ui>, hits: &[SearchHit]) {
|
||||
show_location_column(ui, true);
|
||||
ui.browser.model.remove_all();
|
||||
let counts = listing_summary(
|
||||
hits.iter().filter(|hit| !hit.is_dir).count(),
|
||||
hits.iter().filter(|hit| hit.is_dir).count(),
|
||||
@@ -3067,6 +3092,7 @@ pub(crate) fn repaint_search(ui: &Rc<Ui>, hits: &[SearchHit]) {
|
||||
&gettext("No files or folders match that search."),
|
||||
false,
|
||||
);
|
||||
ui.browser.model.remove_all();
|
||||
return;
|
||||
}
|
||||
browser_views(ui);
|
||||
@@ -3089,9 +3115,7 @@ pub(crate) fn repaint_search(ui: &Rc<Ui>, hits: &[SearchHit]) {
|
||||
})
|
||||
.collect();
|
||||
sort_entries(&mut entries, ui.browser.view.get());
|
||||
for entry in entries {
|
||||
ui.browser.model.append(&BoxedAnyObject::new(entry));
|
||||
}
|
||||
replace_items(&ui.browser.model, &entries);
|
||||
}
|
||||
|
||||
fn listing_summary(files: usize, folders: usize, file_bytes: u64) -> String {
|
||||
|
||||
@@ -19,7 +19,8 @@ pub(crate) struct DevicesState {
|
||||
/// current device is filtered out of the "Other computers" rows, so this is
|
||||
/// where its rename target lives. `None` until identified.
|
||||
pub(crate) this_device: RefCell<Option<(String, String)>>,
|
||||
pub(crate) inflight: Cell<bool>,
|
||||
/// Runs the loads; the rows stay up while one runs.
|
||||
pub(crate) loader: Rc<Loader>,
|
||||
pub(crate) loaded_at: Cell<Option<Instant>>,
|
||||
}
|
||||
|
||||
@@ -148,17 +149,16 @@ pub(crate) fn devices_status(ui: &Rc<Ui>, icon: &str, title: &str, description:
|
||||
/// repaint both sections. The two requests are chained so a single unreachable
|
||||
/// daemon collapses the whole page to a status view.
|
||||
pub(crate) fn load_devices(ui: &Rc<Ui>) {
|
||||
if ui.devices.inflight.get() {
|
||||
return;
|
||||
}
|
||||
ui.devices.inflight.set(true);
|
||||
devices_status(
|
||||
ui,
|
||||
"computer-symbolic",
|
||||
&gettext("Loading…"),
|
||||
&gettext("Reading your computers."),
|
||||
false,
|
||||
);
|
||||
let ui_p = ui.clone();
|
||||
let ticket = ui.devices.loader.refresh(move || {
|
||||
devices_status(
|
||||
&ui_p,
|
||||
"computer-symbolic",
|
||||
&gettext("Loading…"),
|
||||
&gettext("Reading your computers."),
|
||||
false,
|
||||
);
|
||||
});
|
||||
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
|
||||
@@ -169,14 +169,16 @@ pub(crate) fn load_devices(ui: &Rc<Ui>) {
|
||||
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(_) => {
|
||||
ui.busy_end();
|
||||
ui.devices.inflight.set(false);
|
||||
ui.devices.loaded_at.set(None);
|
||||
devices_unreachable(&ui);
|
||||
return;
|
||||
@@ -190,8 +192,6 @@ pub(crate) fn load_devices(ui: &Rc<Ui>) {
|
||||
Ok(Ok(Response::Devices { items })) => items,
|
||||
_ => Vec::new(),
|
||||
};
|
||||
ui.busy_end();
|
||||
ui.devices.inflight.set(false);
|
||||
repaint_devices(&ui, &devices);
|
||||
ui.devices.loaded_at.set(Some(Instant::now()));
|
||||
});
|
||||
|
||||
@@ -21,6 +21,9 @@ pub(crate) struct LocationsState {
|
||||
pub(crate) group: adw::PreferencesGroup,
|
||||
pub(crate) rows: RefCell<Vec<LocationRow>>,
|
||||
pub(crate) inflight: Cell<bool>,
|
||||
/// Shows the loading state if a full load is slow; the rows stay up
|
||||
/// until then.
|
||||
pub(crate) loader: Rc<Loader>,
|
||||
pub(crate) loaded_at: Cell<Option<Instant>>,
|
||||
pub(crate) card: SyncCard,
|
||||
pub(crate) queue: QueueState,
|
||||
@@ -800,13 +803,16 @@ pub(crate) fn load_locations(ui: &Rc<Ui>) {
|
||||
return;
|
||||
}
|
||||
ui.locations.inflight.set(true);
|
||||
locations_status(
|
||||
ui,
|
||||
"drive-harddisk-symbolic",
|
||||
&gettext("Loading…"),
|
||||
&gettext("Reading this computer's Proton Drive locations."),
|
||||
false,
|
||||
);
|
||||
let ui_p = ui.clone();
|
||||
let ticket = ui.locations.loader.refresh(move || {
|
||||
locations_status(
|
||||
&ui_p,
|
||||
"drive-harddisk-symbolic",
|
||||
&gettext("Loading…"),
|
||||
&gettext("Reading this computer's Proton Drive locations."),
|
||||
false,
|
||||
);
|
||||
});
|
||||
ui.busy_begin();
|
||||
let rx = spawn_request(ui.dirs.control_socket(), Request::ListLocations);
|
||||
let ui = ui.clone();
|
||||
@@ -814,6 +820,7 @@ pub(crate) fn load_locations(ui: &Rc<Ui>) {
|
||||
let result = rx.recv().await;
|
||||
ui.busy_end();
|
||||
ui.locations.inflight.set(false);
|
||||
drop(ticket);
|
||||
match result {
|
||||
Ok(Ok(Response::Locations { items })) => {
|
||||
ui.locations.content.set_visible_child_name("list");
|
||||
|
||||
@@ -20,8 +20,12 @@ pub(crate) struct SharedState {
|
||||
/// mount, so descending is uid-addressed and the stack *is* the breadcrumb.
|
||||
pub(crate) nav: RefCell<Vec<(String, String)>>,
|
||||
pub(crate) rows: RefCell<Vec<(adw::PreferencesGroup, gtk4::Widget)>>,
|
||||
/// Guards the Shared page's load so overlapping navigations don't stack.
|
||||
pub(crate) inflight: Cell<bool>,
|
||||
/// Runs the loads. A newer one, such as a navigation made while a folder
|
||||
/// was loading, supersedes the one before it.
|
||||
pub(crate) loader: Rc<Loader>,
|
||||
/// The [`SharedState::nav`] the rows on screen were painted for, or `None`
|
||||
/// before the first paint.
|
||||
pub(crate) listed: RefCell<Option<Vec<(String, String)>>>,
|
||||
/// When the Shared page last painted good data. `None` = never / invalidated,
|
||||
/// forcing a fetch on next visit. See [`PAGE_TTL`].
|
||||
pub(crate) loaded_at: Cell<Option<Instant>>,
|
||||
@@ -163,22 +167,16 @@ pub(crate) fn shared_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &
|
||||
/// children are fetched: invitations and bookmarks belong to the top level, and
|
||||
/// carrying them down a subtree would read as if they lived there.
|
||||
pub(crate) fn load_shared(ui: &Rc<Ui>) {
|
||||
if ui.shared.inflight.get() {
|
||||
return;
|
||||
}
|
||||
cancel_file_thumbnails(ui);
|
||||
let current = ui.shared.nav.borrow().last().cloned();
|
||||
if let Some((uid, _)) = current {
|
||||
load_shared_folder(ui, uid);
|
||||
return;
|
||||
}
|
||||
ui.shared.inflight.set(true);
|
||||
shared_status(
|
||||
let ticket = begin_shared_load(
|
||||
ui,
|
||||
"pdfs-people-symbolic",
|
||||
&gettext("Loading…"),
|
||||
&gettext("Reading your shared items."),
|
||||
false,
|
||||
gettext("Reading your shared items."),
|
||||
);
|
||||
|
||||
ui.busy_begin();
|
||||
@@ -192,7 +190,9 @@ pub(crate) fn load_shared(ui: &Rc<Ui>) {
|
||||
let invites = invites_rx.recv().await;
|
||||
let bookmarks = bookmarks_rx.recv().await;
|
||||
ui.busy_end();
|
||||
ui.shared.inflight.set(false);
|
||||
if !ticket.is_current() {
|
||||
return;
|
||||
}
|
||||
|
||||
// A transport failure on any of the three means the daemon isn't up.
|
||||
if matches!(shared, Ok(Err(_)) | Err(_))
|
||||
@@ -225,13 +225,10 @@ pub(crate) fn load_shared(ui: &Rc<Ui>) {
|
||||
/// The uid comes from the row that was activated (or from the nav stack on a
|
||||
/// reload) — a shared subtree is reachable no other way.
|
||||
fn load_shared_folder(ui: &Rc<Ui>, uid: String) {
|
||||
ui.shared.inflight.set(true);
|
||||
shared_status(
|
||||
let ticket = begin_shared_load(
|
||||
ui,
|
||||
"folder-symbolic",
|
||||
&gettext("Loading…"),
|
||||
&gettext("Reading this shared folder."),
|
||||
false,
|
||||
gettext("Reading this shared folder."),
|
||||
);
|
||||
ui.busy_begin();
|
||||
let rx = spawn_request(ui.dirs.control_socket(), Request::ListSharedFolder { uid });
|
||||
@@ -239,7 +236,9 @@ fn load_shared_folder(ui: &Rc<Ui>, uid: String) {
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
ui.busy_end();
|
||||
ui.shared.inflight.set(false);
|
||||
if !ticket.is_current() {
|
||||
return;
|
||||
}
|
||||
match result {
|
||||
Ok(Ok(Response::Entries { entries })) => {
|
||||
repaint_shared_folder(&ui, &entries);
|
||||
@@ -260,6 +259,22 @@ fn load_shared_folder(ui: &Rc<Ui>, uid: String) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Start a load of the view [`SharedState::nav`] points at. A reload of the
|
||||
/// view on screen keeps its rows up and usable. Moving to another view greys
|
||||
/// them out until the new rows arrive, since they belong to another folder.
|
||||
fn begin_shared_load(ui: &Rc<Ui>, icon: &'static str, description: String) -> LoadTicket {
|
||||
let ui_p = ui.clone();
|
||||
let placeholder = move || {
|
||||
shared_status(&ui_p, icon, &gettext("Loading…"), &description, false);
|
||||
};
|
||||
if *ui.shared.listed.borrow() == Some(ui.shared.nav.borrow().clone()) {
|
||||
ui.shared.loader.refresh(placeholder)
|
||||
} else {
|
||||
ui.shared.listed.borrow_mut().take();
|
||||
ui.shared.loader.replace(placeholder)
|
||||
}
|
||||
}
|
||||
|
||||
/// The header subtitle inside a shared folder: the folders from the share root
|
||||
/// down, the way a breadcrumb reads.
|
||||
pub(crate) fn shared_path(nav: &[(String, String)]) -> String {
|
||||
@@ -295,6 +310,7 @@ fn repaint_shared_folder(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||
|
||||
let nav = ui.shared.nav.borrow().clone();
|
||||
ui.shared.title.set_subtitle(&shared_path(&nav));
|
||||
*ui.shared.listed.borrow_mut() = Some(nav);
|
||||
ui.shared.with_me_group.set_title("");
|
||||
|
||||
let mut rows: Vec<(adw::PreferencesGroup, gtk4::Widget)> = Vec::new();
|
||||
@@ -529,6 +545,7 @@ pub(crate) fn repaint_shared(
|
||||
for (group, row) in ui.shared.rows.borrow_mut().drain(..) {
|
||||
group.remove(&row);
|
||||
}
|
||||
*ui.shared.listed.borrow_mut() = Some(Vec::new());
|
||||
ui.shared.title.set_subtitle("");
|
||||
ui.shared.back.set_visible(false);
|
||||
ui.shared.add_bookmark.set_visible(true);
|
||||
|
||||
@@ -8,7 +8,8 @@ pub(crate) struct SharedByMeState {
|
||||
pub(crate) retry: gtk4::Button,
|
||||
pub(crate) group: adw::PreferencesGroup,
|
||||
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
|
||||
pub(crate) inflight: Cell<bool>,
|
||||
/// Runs the loads; the rows stay up while one runs.
|
||||
pub(crate) loader: Rc<Loader>,
|
||||
pub(crate) loaded_at: Cell<Option<Instant>>,
|
||||
}
|
||||
|
||||
@@ -98,25 +99,26 @@ pub(crate) fn shared_by_me_status(
|
||||
|
||||
/// Fetch the shared-by-me listing and repaint the page.
|
||||
pub(crate) fn load_shared_by_me(ui: &Rc<Ui>) {
|
||||
if ui.shared_by_me.inflight.get() {
|
||||
return;
|
||||
}
|
||||
cancel_file_thumbnails(ui);
|
||||
ui.shared_by_me.inflight.set(true);
|
||||
shared_by_me_status(
|
||||
ui,
|
||||
"pdfs-share-symbolic",
|
||||
&gettext("Loading…"),
|
||||
&gettext("Reading what you've shared."),
|
||||
false,
|
||||
);
|
||||
let ui_p = ui.clone();
|
||||
let ticket = ui.shared_by_me.loader.refresh(move || {
|
||||
shared_by_me_status(
|
||||
&ui_p,
|
||||
"pdfs-share-symbolic",
|
||||
&gettext("Loading…"),
|
||||
&gettext("Reading what you've shared."),
|
||||
false,
|
||||
);
|
||||
});
|
||||
ui.busy_begin();
|
||||
let rx = spawn_request(ui.dirs.control_socket(), Request::ListSharedByMe);
|
||||
let ui = ui.clone();
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
ui.busy_end();
|
||||
ui.shared_by_me.inflight.set(false);
|
||||
if !ticket.is_current() {
|
||||
return;
|
||||
}
|
||||
match result {
|
||||
Ok(Ok(Response::SharedByMe { items })) => {
|
||||
repaint_shared_by_me(&ui, &items);
|
||||
|
||||
@@ -1262,6 +1262,10 @@ pub(crate) fn refresh_status(ui: &Rc<Ui>) {
|
||||
});
|
||||
repaint_pins(&ui, &pins, true);
|
||||
}
|
||||
// The daemon is up but stuck. Say so, and keep the mount state:
|
||||
// announcing "disconnected" for a slow reply would be wrong, and
|
||||
// the next tick asks again.
|
||||
Ok(Err(e)) if e.is_timeout() => paint_sync_status(&ui, SyncState::NotResponding),
|
||||
// Daemon unreachable (still starting, or down): report not-mounted and
|
||||
// grey out the unpin buttons in place, but leave the last-known pin
|
||||
// rows and cache read-out so the page doesn't flicker on a blip.
|
||||
@@ -1305,13 +1309,15 @@ pub(crate) enum SyncState {
|
||||
},
|
||||
/// No mount daemon answered: still starting, or the service is down.
|
||||
Disconnected,
|
||||
/// The daemon is running but didn't answer in time.
|
||||
NotResponding,
|
||||
}
|
||||
|
||||
/// Paint the sidebar status strip and the Sync page's status card: icon,
|
||||
/// one-line state, and a detail line.
|
||||
fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
|
||||
let paused = matches!(state, SyncState::Paused { .. });
|
||||
let connected = !matches!(state, SyncState::Disconnected);
|
||||
let connected = !matches!(state, SyncState::Disconnected | SyncState::NotResponding);
|
||||
let (icon, class, title, detail) = match state {
|
||||
SyncState::Paused { until, queued } => (
|
||||
"media-playback-pause-symbolic",
|
||||
@@ -1366,6 +1372,12 @@ fn paint_sync_status(ui: &Rc<Ui>, state: SyncState) {
|
||||
gettext("Not connected"),
|
||||
Some(gettext("Proton Drive isn't running")),
|
||||
),
|
||||
SyncState::NotResponding => (
|
||||
"dialog-warning-symbolic",
|
||||
Some("warning"),
|
||||
gettext("Not responding"),
|
||||
Some(gettext("Proton Drive is taking too long to answer")),
|
||||
),
|
||||
};
|
||||
let image = &ui.status.status_icon;
|
||||
image.set_icon_name(Some(icon));
|
||||
|
||||
@@ -15,6 +15,8 @@ pub(crate) struct TrashState {
|
||||
/// The bottom bar acting on the selection; revealed while anything is selected.
|
||||
pub(crate) selection_bar: gtk4::Revealer,
|
||||
pub(crate) selection_label: gtk4::Label,
|
||||
/// Runs the loads; the rows stay up while one runs.
|
||||
pub(crate) loader: Rc<Loader>,
|
||||
}
|
||||
|
||||
/// The widgets of the Trash page that a load repaints.
|
||||
@@ -285,18 +287,21 @@ pub(crate) fn bound_entry(item: >k4::ListItem) -> Option<DirEntry> {
|
||||
/// Fetch the trash listing and repaint the page.
|
||||
pub(crate) fn load_trash(ui: &Rc<Ui>) {
|
||||
cancel_file_thumbnails(ui);
|
||||
// Drop the old rows first: a stale row here would offer Restore on something
|
||||
// that may already be gone.
|
||||
ui.trash.model.remove_all();
|
||||
ui.trash.empty.set_sensitive(false);
|
||||
sync_trash_selection(ui);
|
||||
trash_status(
|
||||
ui,
|
||||
"user-trash-symbolic",
|
||||
&gettext("Loading…"),
|
||||
&gettext("Reading the trash."),
|
||||
false,
|
||||
);
|
||||
// The rows stay up while the trash is read again. Trashed items are
|
||||
// addressed by uid, so acting on one that has gone meanwhile fails
|
||||
// cleanly rather than hitting something else.
|
||||
let ui_p = ui.clone();
|
||||
let ticket = ui.trash.loader.refresh(move || {
|
||||
clear_trash(&ui_p);
|
||||
ui_p.trash.empty.set_sensitive(false);
|
||||
trash_status(
|
||||
&ui_p,
|
||||
"user-trash-symbolic",
|
||||
&gettext("Loading…"),
|
||||
&gettext("Reading the trash."),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
ui.busy_begin();
|
||||
let rx = spawn_request(ui.dirs.control_socket(), Request::ListTrash);
|
||||
@@ -304,6 +309,12 @@ pub(crate) fn load_trash(ui: &Rc<Ui>) {
|
||||
glib::spawn_future_local(async move {
|
||||
let result = rx.recv().await;
|
||||
ui.busy_end();
|
||||
if !ticket.is_current() {
|
||||
return;
|
||||
}
|
||||
if !matches!(result, Ok(Ok(Response::Entries { .. }))) {
|
||||
clear_trash(&ui);
|
||||
}
|
||||
match result {
|
||||
Ok(Ok(Response::Entries { entries })) => repaint_trash(&ui, &entries),
|
||||
Ok(Ok(Response::Error { message, .. })) => trash_status(
|
||||
@@ -344,8 +355,6 @@ pub(crate) fn trash_status(ui: &Rc<Ui>, icon: &str, title: &str, description: &s
|
||||
/// Repopulate the trash list, most recently modified first — the order in which a
|
||||
/// user looks for what they just deleted.
|
||||
pub(crate) fn repaint_trash(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||
ui.trash.model.remove_all();
|
||||
sync_trash_selection(ui);
|
||||
// An empty Trash has nothing to empty, so the button goes away rather than
|
||||
// sitting there greyed out.
|
||||
ui.trash.empty.set_sensitive(true);
|
||||
@@ -358,6 +367,7 @@ pub(crate) fn repaint_trash(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||
&gettext("Items you delete from Proton Drive show up here."),
|
||||
false,
|
||||
);
|
||||
clear_trash(ui);
|
||||
return;
|
||||
}
|
||||
ui.trash.content.set_visible_child_name("list");
|
||||
@@ -370,9 +380,16 @@ pub(crate) fn repaint_trash(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||
|
||||
let mut sorted = entries.to_vec();
|
||||
sorted.sort_by_key(|e| std::cmp::Reverse(e.modified));
|
||||
for entry in sorted {
|
||||
ui.trash.model.append(&BoxedAnyObject::new(entry));
|
||||
}
|
||||
// Only the rows that changed are swapped, so a refresh keeps the
|
||||
// selection and the scroll position.
|
||||
replace_items(&ui.trash.model, &sorted);
|
||||
sync_trash_selection(ui);
|
||||
}
|
||||
|
||||
/// Drop every row, and with them the selection and its bar.
|
||||
fn clear_trash(ui: &Rc<Ui>) {
|
||||
ui.trash.model.remove_all();
|
||||
sync_trash_selection(ui);
|
||||
}
|
||||
|
||||
/// Restore one trashed entry to the folder it was trashed from.
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
//! Reloading a page without flashing it.
|
||||
//!
|
||||
//! Pages used to swap their content for a "Loading…" status page on every
|
||||
//! load, so every sidebar click and every folder change blinked. A [`Loader`]
|
||||
//! keeps the last content on screen while the request runs and shows the
|
||||
//! loading state only if the reply is slow. [`replace_items`] then swaps in the
|
||||
//! new rows by changing only the ones that differ, so selection and scroll
|
||||
//! position survive a refresh.
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
|
||||
use gtk4::gio;
|
||||
use gtk4::glib;
|
||||
use gtk4::glib::BoxedAnyObject;
|
||||
use gtk4::glib::object::Cast;
|
||||
use gtk4::prelude::*;
|
||||
|
||||
/// How long a load may run before the page gives up its old content for a
|
||||
/// loading state. Most replies arrive well within this, so the page updates in
|
||||
/// place.
|
||||
const LOADING_GRACE: Duration = Duration::from_millis(300);
|
||||
|
||||
/// One page's loads. Each [`Loader::refresh`] or [`Loader::replace`]
|
||||
/// supersedes the one before it.
|
||||
pub(crate) struct Loader {
|
||||
content: gtk4::Widget,
|
||||
generation: Cell<u64>,
|
||||
placeholder: RefCell<Option<glib::SourceId>>,
|
||||
}
|
||||
|
||||
impl Loader {
|
||||
/// A loader for the page whose content is `content`.
|
||||
pub(crate) fn new(content: &impl IsA<gtk4::Widget>) -> Rc<Self> {
|
||||
Rc::new(Loader {
|
||||
content: content.clone().upcast(),
|
||||
generation: Cell::new(0),
|
||||
placeholder: RefCell::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
/// Start a load of what the page already shows. The content stays up and
|
||||
/// usable; `placeholder` runs if the load is still going after
|
||||
/// [`LOADING_GRACE`].
|
||||
pub(crate) fn refresh(self: &Rc<Self>, placeholder: impl FnOnce() + 'static) -> LoadTicket {
|
||||
self.begin(false, placeholder)
|
||||
}
|
||||
|
||||
/// Start a load of something else, such as another folder. The old
|
||||
/// content stays up but insensitive, since acting on it would act on the
|
||||
/// wrong thing; `placeholder` runs if the load is still going after
|
||||
/// [`LOADING_GRACE`].
|
||||
pub(crate) fn replace(self: &Rc<Self>, placeholder: impl FnOnce() + 'static) -> LoadTicket {
|
||||
self.begin(true, placeholder)
|
||||
}
|
||||
|
||||
fn begin(self: &Rc<Self>, stale: bool, placeholder: impl FnOnce() + 'static) -> LoadTicket {
|
||||
let generation = self.generation.get().wrapping_add(1);
|
||||
self.generation.set(generation);
|
||||
self.cancel_placeholder();
|
||||
self.content.set_sensitive(!stale);
|
||||
let loader = Rc::downgrade(self);
|
||||
let source = glib::timeout_add_local_once(LOADING_GRACE, move || {
|
||||
let Some(loader) = loader.upgrade() else {
|
||||
return;
|
||||
};
|
||||
loader.placeholder.borrow_mut().take();
|
||||
loader.content.set_sensitive(true);
|
||||
placeholder();
|
||||
});
|
||||
*self.placeholder.borrow_mut() = Some(source);
|
||||
LoadTicket {
|
||||
loader: self.clone(),
|
||||
generation,
|
||||
}
|
||||
}
|
||||
|
||||
fn cancel_placeholder(&self) {
|
||||
if let Some(source) = self.placeholder.borrow_mut().take() {
|
||||
source.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A load in flight. Dropping it ends the load: the pending loading state is
|
||||
/// cancelled and the content is usable again, unless a newer load has taken
|
||||
/// over in the meantime.
|
||||
pub(crate) struct LoadTicket {
|
||||
loader: Rc<Loader>,
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
impl LoadTicket {
|
||||
/// Whether no newer load has started since this one. A superseded load's
|
||||
/// reply is stale and must not be painted.
|
||||
pub(crate) fn is_current(&self) -> bool {
|
||||
self.loader.generation.get() == self.generation
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for LoadTicket {
|
||||
fn drop(&mut self) {
|
||||
if self.is_current() {
|
||||
self.loader.cancel_placeholder();
|
||||
self.loader.content.set_sensitive(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Make `model`, a list of [`BoxedAnyObject`]s holding `T`, hold `items`,
|
||||
/// replacing only the run of items that changed, in one `items-changed`
|
||||
/// signal. Rows before and after that run keep their objects, so the views
|
||||
/// keep their widgets, the selection and the scroll position.
|
||||
pub(crate) fn replace_items<T: Clone + PartialEq + 'static>(model: &gio::ListStore, items: &[T]) {
|
||||
let held = |i: usize, item: &T| {
|
||||
model
|
||||
.item(i as u32)
|
||||
.and_downcast::<BoxedAnyObject>()
|
||||
.is_some_and(|object| *object.borrow::<T>() == *item)
|
||||
};
|
||||
let Some((start, removed, added)) = changed_run(model.n_items() as usize, items, held) else {
|
||||
return;
|
||||
};
|
||||
let added: Vec<BoxedAnyObject> = items[added]
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(BoxedAnyObject::new)
|
||||
.collect();
|
||||
model.splice(start as u32, removed as u32, &added);
|
||||
}
|
||||
|
||||
/// The one run that differs between an old list of `old_len` items and `new`:
|
||||
/// where it starts, how many old items it covers, and which new items replace
|
||||
/// them. `held(i, item)` says whether old item `i` equals `item`. `None` when
|
||||
/// the lists are equal.
|
||||
fn changed_run<T>(
|
||||
old_len: usize,
|
||||
new: &[T],
|
||||
held: impl Fn(usize, &T) -> bool,
|
||||
) -> Option<(usize, usize, std::ops::Range<usize>)> {
|
||||
let shared = old_len.min(new.len());
|
||||
let prefix = (0..shared).take_while(|&i| held(i, &new[i])).count();
|
||||
let suffix = (0..shared - prefix)
|
||||
.take_while(|&k| held(old_len - 1 - k, &new[new.len() - 1 - k]))
|
||||
.count();
|
||||
let removed = old_len - prefix - suffix;
|
||||
let added = prefix..new.len() - suffix;
|
||||
(removed > 0 || !added.is_empty()).then_some((prefix, removed, added))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::changed_run;
|
||||
|
||||
fn run(old: &[u32], new: &[u32]) -> Option<(usize, usize, Vec<u32>)> {
|
||||
changed_run(old.len(), new, |i, item| old[i] == *item)
|
||||
.map(|(start, removed, added)| (start, removed, new[added].to_vec()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_lists_change_nothing() {
|
||||
assert_eq!(run(&[1, 2, 3], &[1, 2, 3]), None);
|
||||
assert_eq!(run(&[], &[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_differing_middle_is_replaced() {
|
||||
assert_eq!(run(&[1, 2, 3, 4], &[1, 9, 4]), Some((1, 2, vec![9])));
|
||||
assert_eq!(run(&[1, 2, 3], &[1, 2, 5, 3]), Some((2, 0, vec![5])));
|
||||
assert_eq!(run(&[1, 2, 3], &[1, 3]), Some((1, 1, vec![])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn growing_or_shrinking_at_either_end() {
|
||||
assert_eq!(run(&[1, 2], &[0, 1, 2]), Some((0, 0, vec![0])));
|
||||
assert_eq!(run(&[1, 2], &[1, 2, 3]), Some((2, 0, vec![3])));
|
||||
assert_eq!(run(&[1, 2, 3], &[]), Some((0, 3, vec![])));
|
||||
assert_eq!(run(&[], &[1]), Some((0, 0, vec![1])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_items_are_not_counted_twice() {
|
||||
// The prefix takes both 1s; the suffix must not reuse them.
|
||||
assert_eq!(run(&[1, 1], &[1, 1, 1]), Some((2, 0, vec![1])));
|
||||
assert_eq!(run(&[1, 1, 1], &[1, 1]), Some((2, 1, vec![])));
|
||||
}
|
||||
}
|
||||
@@ -212,6 +212,17 @@ fn poll_state(socket: &Path, default_mountpoint: &Path) -> DriveState {
|
||||
paused: false,
|
||||
failing: 0,
|
||||
},
|
||||
// The daemon is up but stuck: say so rather than "not connected", and
|
||||
// keep the mount actions, which still work on the folder itself.
|
||||
Err(e) if e.is_timeout() => DriveState {
|
||||
line: gettext("Proton Drive isn't responding"),
|
||||
phase: Phase::Attention,
|
||||
mounted: true,
|
||||
mountpoint: default_mountpoint.to_path_buf(),
|
||||
sync: String::new(),
|
||||
paused: false,
|
||||
failing: 0,
|
||||
},
|
||||
// No daemon: describe login state instead so the menu is still useful.
|
||||
Err(_) => {
|
||||
let (line, phase) = match auth::load() {
|
||||
|
||||
+629
-617
File diff suppressed because it is too large.
Load diff
+636
-624
File diff suppressed because it is too large.
Load diff
+631
-619
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user