mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 16:56:59 +01:00
feat(gui): activity log redesign
Group the feed by day with a time column and filter chips, fold a conflict logged on every daemon run into one row with a count, and put the conflicts still open in a Needs attention section. Conflict rows open the Sync page's resolve dialog and read as resolved once the copy is gone.
This commit is contained in:
1 parent
f457fa68fc
commit
fe8fb8cdcf
4 files changed
+501
-63
No files matched your search
@@ -586,10 +586,16 @@ fn build_window(app: &adw::Application) {
|
|||||||
content: activity_widgets.content.clone(),
|
content: activity_widgets.content.clone(),
|
||||||
status: activity_widgets.status.clone(),
|
status: activity_widgets.status.clone(),
|
||||||
retry: activity_widgets.retry.clone(),
|
retry: activity_widgets.retry.clone(),
|
||||||
group: activity_widgets.group.clone(),
|
attention: activity_widgets.attention.clone(),
|
||||||
rows: RefCell::new(Vec::new()),
|
attention_rows: RefCell::new(Vec::new()),
|
||||||
|
days: activity_widgets.days.clone(),
|
||||||
inflight: Cell::new(false),
|
inflight: Cell::new(false),
|
||||||
key: RefCell::new(None),
|
key: RefCell::new(None),
|
||||||
|
items: RefCell::new(Vec::new()),
|
||||||
|
filter: Cell::new(ActivityFilter::All),
|
||||||
|
conflicts: RefCell::new(None),
|
||||||
|
conflicts_inflight: Cell::new(false),
|
||||||
|
conflicts_at: Cell::new(None),
|
||||||
},
|
},
|
||||||
takeout: TakeoutState {
|
takeout: TakeoutState {
|
||||||
archives: RefCell::new(Vec::new()),
|
archives: RefCell::new(Vec::new()),
|
||||||
@@ -648,7 +654,7 @@ fn build_window(app: &adw::Application) {
|
|||||||
wire_shared_by_me(&ui, &shared_by_me_widgets.retry);
|
wire_shared_by_me(&ui, &shared_by_me_widgets.retry);
|
||||||
wire_devices(&ui, &devices_widgets.retry, &devices_widgets.restore);
|
wire_devices(&ui, &devices_widgets.retry, &devices_widgets.restore);
|
||||||
wire_locations(&ui, &locations_widgets.retry, &locations_widgets.add_folder);
|
wire_locations(&ui, &locations_widgets.retry, &locations_widgets.add_folder);
|
||||||
wire_activity(&ui, &activity_widgets.retry);
|
wire_activity(&ui, &activity_widgets.retry, &activity_widgets.filters);
|
||||||
wire_takeout(&ui, &takeout_widgets);
|
wire_takeout(&ui, &takeout_widgets);
|
||||||
wire_refresh(
|
wire_refresh(
|
||||||
&ui,
|
&ui,
|
||||||
|
|||||||
@@ -1,38 +1,132 @@
|
|||||||
use crate::*;
|
use crate::*;
|
||||||
|
|
||||||
pub(crate) struct ActivityState {
|
pub(crate) struct ActivityState {
|
||||||
// Activity page: one section, newest-first, rebuilt wholesale on each load.
|
// Activity page: a "Needs attention" section for live conflicts, then the
|
||||||
|
// feed grouped by day, rebuilt wholesale on each change.
|
||||||
pub(crate) content: gtk4::Stack,
|
pub(crate) content: gtk4::Stack,
|
||||||
pub(crate) status: adw::StatusPage,
|
pub(crate) status: adw::StatusPage,
|
||||||
pub(crate) retry: gtk4::Button,
|
pub(crate) retry: gtk4::Button,
|
||||||
pub(crate) group: adw::PreferencesGroup,
|
pub(crate) attention: adw::PreferencesGroup,
|
||||||
pub(crate) rows: RefCell<Vec<gtk4::Widget>>,
|
pub(crate) attention_rows: RefCell<Vec<adw::ActionRow>>,
|
||||||
|
pub(crate) days: gtk4::Box,
|
||||||
pub(crate) inflight: Cell<bool>,
|
pub(crate) inflight: Cell<bool>,
|
||||||
/// Fingerprint of the feed currently on screen (see [`activity_key`]). The
|
/// Fingerprint of what is on screen (see [`activity_key`]). The page polls
|
||||||
/// page polls every couple of seconds and usually gets back exactly what it
|
/// every couple of seconds and usually gets back exactly what it is already
|
||||||
/// is already showing; rebuilding all 200 rows for that would throw away the
|
/// showing; rebuilding every row for that would throw away the user's
|
||||||
/// user's scroll position several times a minute.
|
/// scroll position several times a minute.
|
||||||
pub(crate) key: RefCell<Option<String>>,
|
pub(crate) key: RefCell<Option<String>>,
|
||||||
|
/// The last feed the daemon sent, so a filter change repaints without a
|
||||||
|
/// round-trip.
|
||||||
|
pub(crate) items: RefCell<Vec<ActivityEntry>>,
|
||||||
|
pub(crate) filter: Cell<ActivityFilter>,
|
||||||
|
/// Conflict copies still waiting for a decision, or `None` before the first
|
||||||
|
/// listing. Lets a logged conflict read as resolved once it is gone.
|
||||||
|
pub(crate) conflicts: RefCell<Option<Vec<ConflictInfo>>>,
|
||||||
|
pub(crate) conflicts_inflight: Cell<bool>,
|
||||||
|
pub(crate) conflicts_at: Cell<Option<Instant>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Widgets the Activity page's load/repaint touch.
|
/// Widgets the Activity page's load/repaint touch.
|
||||||
pub(crate) struct ActivityWidgets {
|
pub(crate) struct ActivityWidgets {
|
||||||
pub(crate) content: gtk4::Stack,
|
pub(crate) content: gtk4::Stack,
|
||||||
pub(crate) status: adw::StatusPage,
|
pub(crate) status: adw::StatusPage,
|
||||||
pub(crate) group: adw::PreferencesGroup,
|
pub(crate) attention: adw::PreferencesGroup,
|
||||||
|
pub(crate) days: gtk4::Box,
|
||||||
|
pub(crate) filters: Vec<(ActivityFilter, gtk4::ToggleButton)>,
|
||||||
pub(crate) retry: gtk4::Button,
|
pub(crate) retry: gtk4::Button,
|
||||||
pub(crate) refresh: gtk4::Button,
|
pub(crate) refresh: gtk4::Button,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Which slice of the feed the filter bar shows.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub(crate) enum ActivityFilter {
|
||||||
|
All,
|
||||||
|
Transfers,
|
||||||
|
Changes,
|
||||||
|
Sharing,
|
||||||
|
Problems,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActivityFilter {
|
||||||
|
const ALL: [ActivityFilter; 5] = [
|
||||||
|
ActivityFilter::All,
|
||||||
|
ActivityFilter::Transfers,
|
||||||
|
ActivityFilter::Changes,
|
||||||
|
ActivityFilter::Sharing,
|
||||||
|
ActivityFilter::Problems,
|
||||||
|
];
|
||||||
|
|
||||||
|
fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ActivityFilter::All => "All",
|
||||||
|
ActivityFilter::Transfers => "Transfers",
|
||||||
|
ActivityFilter::Changes => "Changes",
|
||||||
|
ActivityFilter::Sharing => "Sharing",
|
||||||
|
ActivityFilter::Problems => "Problems",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether an entry belongs in this slice. A failed action is a problem
|
||||||
|
/// whatever it was, and also stays under its own kind.
|
||||||
|
pub(crate) fn matches(self, entry: &ActivityEntry) -> bool {
|
||||||
|
use ActivityKind::*;
|
||||||
|
match self {
|
||||||
|
ActivityFilter::All => true,
|
||||||
|
ActivityFilter::Transfers => matches!(entry.kind, Upload | Download | Sync),
|
||||||
|
ActivityFilter::Changes => matches!(
|
||||||
|
entry.kind,
|
||||||
|
Rename | Move | CreateFolder | Trash | Restore | DeleteForever | EmptyTrash
|
||||||
|
),
|
||||||
|
ActivityFilter::Sharing => matches!(entry.kind, Share | PublicLink | Unshare),
|
||||||
|
ActivityFilter::Problems => !entry.ok || entry.kind == Conflict,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The Activity page: a newest-first feed of the mutations and transfers the
|
/// The Activity page: a newest-first feed of the mutations and transfers the
|
||||||
/// daemon performed this session (uploads, deletes, shares, …).
|
/// daemon performed (uploads, deletes, shares, …), grouped by day, with the
|
||||||
|
/// conflicts that still need a decision on top.
|
||||||
pub(crate) fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) {
|
pub(crate) fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) {
|
||||||
let refresh = refresh_button();
|
let refresh = refresh_button();
|
||||||
|
|
||||||
let group = adw::PreferencesGroup::new();
|
// Filter chips: a linked row of radio toggles.
|
||||||
let clamp = adw::Clamp::builder().child(&group).build();
|
let bar = gtk4::Box::new(gtk4::Orientation::Horizontal, 0);
|
||||||
|
bar.add_css_class("linked");
|
||||||
|
bar.set_halign(gtk4::Align::Start);
|
||||||
|
let mut filters: Vec<(ActivityFilter, gtk4::ToggleButton)> = Vec::new();
|
||||||
|
for filter in ActivityFilter::ALL {
|
||||||
|
let button = gtk4::ToggleButton::with_label(filter.label());
|
||||||
|
if let Some((_, first)) = filters.first() {
|
||||||
|
button.set_group(Some(first));
|
||||||
|
} else {
|
||||||
|
button.set_active(true);
|
||||||
|
}
|
||||||
|
bar.append(&button);
|
||||||
|
filters.push((filter, button));
|
||||||
|
}
|
||||||
|
|
||||||
|
let attention = adw::PreferencesGroup::builder()
|
||||||
|
.title("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()
|
||||||
|
.maximum_size(900)
|
||||||
|
.tightening_threshold(600)
|
||||||
|
.child(&column)
|
||||||
|
.build();
|
||||||
let scroll = gtk4::ScrolledWindow::builder()
|
let scroll = gtk4::ScrolledWindow::builder()
|
||||||
.vexpand(true)
|
.vexpand(true)
|
||||||
|
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||||
.child(&clamp)
|
.child(&clamp)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
@@ -56,13 +150,7 @@ pub(crate) fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) {
|
|||||||
content.add_named(&scroll, Some("list"));
|
content.add_named(&scroll, Some("list"));
|
||||||
content.add_named(&status, Some("status"));
|
content.add_named(&status, Some("status"));
|
||||||
|
|
||||||
let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
let (frame, header, _) = page_frame("Activity", &content);
|
||||||
inner.set_margin_top(18);
|
|
||||||
inner.set_margin_bottom(18);
|
|
||||||
inner.set_margin_start(18);
|
|
||||||
inner.set_margin_end(18);
|
|
||||||
inner.append(&content);
|
|
||||||
let (frame, header, _) = page_frame("Activity", &inner);
|
|
||||||
header.pack_end(&refresh);
|
header.pack_end(&refresh);
|
||||||
|
|
||||||
(
|
(
|
||||||
@@ -70,7 +158,9 @@ pub(crate) fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) {
|
|||||||
ActivityWidgets {
|
ActivityWidgets {
|
||||||
content,
|
content,
|
||||||
status,
|
status,
|
||||||
group,
|
attention,
|
||||||
|
days,
|
||||||
|
filters,
|
||||||
retry,
|
retry,
|
||||||
refresh,
|
refresh,
|
||||||
},
|
},
|
||||||
@@ -78,12 +168,26 @@ pub(crate) fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Install the Activity page's retry button.
|
/// Install the Activity page's retry button.
|
||||||
pub(crate) fn wire_activity(ui: &Rc<Ui>, retry: >k4::Button) {
|
pub(crate) fn wire_activity(
|
||||||
|
ui: &Rc<Ui>,
|
||||||
|
retry: >k4::Button,
|
||||||
|
filters: &[(ActivityFilter, gtk4::ToggleButton)],
|
||||||
|
) {
|
||||||
let ui_retry = ui.clone();
|
let ui_retry = ui.clone();
|
||||||
retry.connect_clicked(move |_| {
|
retry.connect_clicked(move |_| {
|
||||||
service::restart();
|
service::restart();
|
||||||
load_activity(&ui_retry);
|
load_activity(&ui_retry);
|
||||||
});
|
});
|
||||||
|
for (filter, button) in filters {
|
||||||
|
let ui = ui.clone();
|
||||||
|
let filter = *filter;
|
||||||
|
button.connect_toggled(move |button| {
|
||||||
|
if button.is_active() {
|
||||||
|
ui.activity.filter.set(filter);
|
||||||
|
paint_activity(&ui);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Show a status page in place of the Activity list.
|
/// Show a status page in place of the Activity list.
|
||||||
@@ -109,6 +213,7 @@ pub(crate) fn activity_status(
|
|||||||
/// pass fills the feed as it works rather than only once it is done. Anything
|
/// pass fills the feed as it works rather than only once it is done. Anything
|
||||||
/// other than a good answer leaves the rows alone until the next tick.
|
/// other than a good answer leaves the rows alone until the next tick.
|
||||||
pub(crate) fn refresh_activity(ui: &Rc<Ui>) {
|
pub(crate) fn refresh_activity(ui: &Rc<Ui>) {
|
||||||
|
refresh_activity_conflicts(ui, false);
|
||||||
if ui.activity.inflight.get() {
|
if ui.activity.inflight.get() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -132,6 +237,7 @@ pub(crate) fn refresh_activity(ui: &Rc<Ui>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn load_activity(ui: &Rc<Ui>) {
|
pub(crate) fn load_activity(ui: &Rc<Ui>) {
|
||||||
|
refresh_activity_conflicts(ui, true);
|
||||||
if ui.activity.inflight.get() {
|
if ui.activity.inflight.get() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -201,28 +307,143 @@ pub(crate) fn activity_unreachable(ui: &Rc<Ui>) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A cheap fingerprint of a feed: how many entries, and what the newest one is.
|
/// Poll the conflicts still waiting for a decision while the Activity page is
|
||||||
/// The log is append-only and newest-first, so a feed with the same length and
|
/// on screen. `force` skips the TTL, for navigation and right after a
|
||||||
/// the same newest entry is the same feed.
|
/// resolution. Listing walks every node the daemon knows, so the tick asks far
|
||||||
pub(crate) fn activity_key(items: &[ActivityEntry]) -> String {
|
/// less often than it does for the feed.
|
||||||
match items.first() {
|
pub(crate) fn refresh_activity_conflicts(ui: &Rc<Ui>, force: bool) {
|
||||||
Some(a) => format!("{}:{}:{}:{}", items.len(), a.time, a.target, a.detail),
|
let state = &ui.activity;
|
||||||
None => String::new(),
|
if state.conflicts_inflight.get()
|
||||||
}
|
|| (!force
|
||||||
}
|
&& state
|
||||||
|
.conflicts_at
|
||||||
/// Rebuild the Activity section from a fresh log, unless it is already showing
|
.get()
|
||||||
/// exactly this feed.
|
.is_some_and(|at| at.elapsed() < CONFLICTS_TTL))
|
||||||
pub(crate) fn repaint_activity(ui: &Rc<Ui>, items: &[ActivityEntry]) {
|
{
|
||||||
let key = activity_key(items);
|
|
||||||
if ui.activity.key.borrow().as_deref() == Some(key.as_str()) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
*ui.activity.key.borrow_mut() = Some(key);
|
state.conflicts_inflight.set(true);
|
||||||
for row in ui.activity.rows.borrow_mut().drain(..) {
|
let rx = spawn_request(ui.dirs.control_socket(), Request::ListConflicts);
|
||||||
ui.activity.group.remove(&row);
|
let ui = ui.clone();
|
||||||
|
glib::spawn_future_local(async move {
|
||||||
|
let result = rx.recv().await;
|
||||||
|
let state = &ui.activity;
|
||||||
|
state.conflicts_inflight.set(false);
|
||||||
|
state.conflicts_at.set(Some(Instant::now()));
|
||||||
|
// An older daemon, or none, has no list: leave the logged conflicts as
|
||||||
|
// they are rather than calling them resolved.
|
||||||
|
let items = match result {
|
||||||
|
Ok(Ok(Response::Conflicts { items })) => Some(items),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
*state.conflicts.borrow_mut() = items;
|
||||||
|
if ui.stack.visible_child_name().as_deref() == Some("activity") {
|
||||||
|
paint_activity(&ui);
|
||||||
}
|
}
|
||||||
if items.is_empty() {
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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 feed row: an entry and how many times it was logged in a row (or, for
|
||||||
|
/// a conflict, at all).
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) struct FeedRow<'a> {
|
||||||
|
pub(crate) entry: &'a ActivityEntry,
|
||||||
|
pub(crate) count: usize,
|
||||||
|
/// When the oldest collapsed repeat happened.
|
||||||
|
pub(crate) first: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collapse repeats so the feed reads as news. The daemon flags a divergent
|
||||||
|
/// conflict once per run, so the same copy comes back every day it stays
|
||||||
|
/// unresolved: every conflict entry folds into its newest mention. Any other
|
||||||
|
/// entry folds only into an identical one right before it (a retry loop
|
||||||
|
/// failing the same way).
|
||||||
|
pub(crate) fn collapse_feed(items: &[ActivityEntry]) -> Vec<FeedRow<'_>> {
|
||||||
|
let same = |a: &ActivityEntry, b: &ActivityEntry| {
|
||||||
|
a.kind == b.kind && a.ok == b.ok && a.target == b.target && a.detail == b.detail
|
||||||
|
};
|
||||||
|
let mut rows: Vec<FeedRow> = Vec::new();
|
||||||
|
for entry in items {
|
||||||
|
let prior = if entry.kind == ActivityKind::Conflict {
|
||||||
|
rows.iter_mut().find(|row| same(row.entry, entry))
|
||||||
|
} else {
|
||||||
|
rows.last_mut().filter(|row| same(row.entry, entry))
|
||||||
|
};
|
||||||
|
match prior {
|
||||||
|
Some(row) => {
|
||||||
|
row.count += 1;
|
||||||
|
row.first = row.first.min(entry.time);
|
||||||
|
}
|
||||||
|
None => rows.push(FeedRow {
|
||||||
|
entry,
|
||||||
|
count: 1,
|
||||||
|
first: entry.time,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A section title for the day `at` falls on, seen from `now`: "Today",
|
||||||
|
/// "Yesterday", a weekday within the week, then a date.
|
||||||
|
pub(crate) fn day_label(at: &glib::DateTime, now: &glib::DateTime) -> String {
|
||||||
|
let today = now.ymd();
|
||||||
|
if at.ymd() == today {
|
||||||
|
return "Today".to_string();
|
||||||
|
}
|
||||||
|
if now.add_days(-1).is_ok_and(|y| y.ymd() == at.ymd()) {
|
||||||
|
return "Yesterday".to_string();
|
||||||
|
}
|
||||||
|
let format = if now.difference(at).as_seconds() < 6 * 86_400 {
|
||||||
|
"%A"
|
||||||
|
} else if at.year() == now.year() {
|
||||||
|
"%A, %B %-d"
|
||||||
|
} else {
|
||||||
|
"%B %-d, %Y"
|
||||||
|
};
|
||||||
|
at.format(format).map(|s| s.to_string()).unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebuild the page from the kept feed and conflict list, unless it already
|
||||||
|
/// shows exactly this.
|
||||||
|
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();
|
||||||
|
if items.is_empty() && open.is_empty() {
|
||||||
activity_status(
|
activity_status(
|
||||||
ui,
|
ui,
|
||||||
"document-open-recent-symbolic",
|
"document-open-recent-symbolic",
|
||||||
@@ -232,40 +453,149 @@ pub(crate) fn repaint_activity(ui: &Rc<Ui>, items: &[ActivityEntry]) {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
ui.activity.content.set_visible_child_name("list");
|
*state.key.borrow_mut() = Some(key);
|
||||||
let mut rows: Vec<gtk4::Widget> = Vec::new();
|
state.content.set_visible_child_name("list");
|
||||||
for a in items {
|
|
||||||
let mut subtitle = activity_time(a.time);
|
// Needs attention: the live conflicts, each opening the resolve dialog.
|
||||||
if !a.detail.is_empty() {
|
for row in state.attention_rows.borrow_mut().drain(..) {
|
||||||
subtitle = format!("{subtitle} · {}", a.detail);
|
state.attention.remove(&row);
|
||||||
}
|
}
|
||||||
|
state.attention.set_visible(!open.is_empty());
|
||||||
|
state.attention.set_description(Some(&match open.len() {
|
||||||
|
1 => "A file was changed in two places at once. Choose which version to keep.".to_string(),
|
||||||
|
n => {
|
||||||
|
format!("{n} files were changed in two places at once. Choose which versions to keep.")
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
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("No matching activity")
|
||||||
|
.description("Nothing in the recent log fits this filter.")
|
||||||
|
.build();
|
||||||
|
empty.add_css_class("compact");
|
||||||
|
state.days.append(&empty);
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One row of the feed.
|
||||||
|
fn feed_row(
|
||||||
|
ui: &Rc<Ui>,
|
||||||
|
row: &FeedRow,
|
||||||
|
at: Option<&glib::DateTime>,
|
||||||
|
open: &[ConflictInfo],
|
||||||
|
) -> adw::ActionRow {
|
||||||
|
let a = row.entry;
|
||||||
let title = match a.kind {
|
let title = match a.kind {
|
||||||
// The target of an empty is a count, not a name to lead with a verb.
|
// The target of an empty is a count, not a name to lead with a verb.
|
||||||
ActivityKind::EmptyTrash => format!("Emptied Trash ({})", a.target),
|
ActivityKind::EmptyTrash => format!("Emptied Trash ({})", a.target),
|
||||||
kind => format!("{} {}", activity_verb(kind), a.target),
|
kind => format!("{} {}", activity_verb(kind), a.target),
|
||||||
};
|
};
|
||||||
let row = adw::ActionRow::builder()
|
// 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 {
|
||||||
|
subtitle.push(format!(
|
||||||
|
"{} times since {}",
|
||||||
|
row.count,
|
||||||
|
activity_time(row.first)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if resolved {
|
||||||
|
subtitle.push("Resolved".to_string());
|
||||||
|
}
|
||||||
|
let widget = adw::ActionRow::builder()
|
||||||
.title(title)
|
.title(title)
|
||||||
.subtitle(subtitle)
|
.subtitle(subtitle.join(" · "))
|
||||||
|
.use_markup(false)
|
||||||
|
.title_lines(1)
|
||||||
|
.subtitle_lines(2)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// A failure and a conflict are different news: a conflict kept both
|
// 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.
|
// copies, a failure lost the action. Each gets its own icon and colour.
|
||||||
let icon = gtk4::Image::from_icon_name(if a.ok {
|
let icon = gtk4::Image::from_icon_name(if resolved {
|
||||||
|
"object-select-symbolic"
|
||||||
|
} else if a.ok || a.kind == ActivityKind::Conflict {
|
||||||
activity_icon(a.kind)
|
activity_icon(a.kind)
|
||||||
} else {
|
} else {
|
||||||
"dialog-error-symbolic"
|
"dialog-error-symbolic"
|
||||||
});
|
});
|
||||||
if !a.ok {
|
if resolved {
|
||||||
icon.add_css_class("error");
|
icon.add_css_class("success");
|
||||||
} else if a.kind == ActivityKind::Conflict {
|
} else if a.kind == ActivityKind::Conflict {
|
||||||
icon.add_css_class("warning");
|
icon.add_css_class("warning");
|
||||||
|
} else if !a.ok {
|
||||||
|
icon.add_css_class("error");
|
||||||
}
|
}
|
||||||
row.add_prefix(&icon);
|
widget.add_prefix(&icon);
|
||||||
row.set_activatable(false);
|
|
||||||
ui.activity.group.add(&row);
|
let time = gtk4::Label::new(Some(
|
||||||
rows.push(row.upcast());
|
&at.and_then(|at| at.format("%H:%M").ok())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
));
|
||||||
|
time.add_css_class("dim-label");
|
||||||
|
time.add_css_class("numeric");
|
||||||
|
if let Some(at) = at.and_then(|at| at.format("%c").ok()) {
|
||||||
|
time.set_tooltip_text(Some(&at));
|
||||||
}
|
}
|
||||||
*ui.activity.rows.borrow_mut() = rows;
|
widget.add_suffix(&time);
|
||||||
|
|
||||||
|
if let Some(conflict) = pending {
|
||||||
|
let resolve = gtk4::Button::builder()
|
||||||
|
.label("Resolve…")
|
||||||
|
.valign(gtk4::Align::Center)
|
||||||
|
.build();
|
||||||
|
let ui = ui.clone();
|
||||||
|
let conflict = conflict.clone();
|
||||||
|
resolve.connect_clicked(move |_| prompt_resolve_conflict(&ui, &conflict));
|
||||||
|
widget.add_suffix(&resolve);
|
||||||
|
}
|
||||||
|
widget
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A human verb for an activity kind, used as the row title's lead word.
|
/// A human verb for an activity kind, used as the row title's lead word.
|
||||||
@@ -313,3 +643,99 @@ pub(crate) fn activity_time(secs: i64) -> String {
|
|||||||
.map(|s| s.to_string())
|
.map(|s| s.to_string())
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn entry(time: i64, kind: ActivityKind, target: &str, detail: &str, ok: bool) -> ActivityEntry {
|
||||||
|
ActivityEntry {
|
||||||
|
time,
|
||||||
|
kind,
|
||||||
|
target: target.into(),
|
||||||
|
detail: detail.into(),
|
||||||
|
ok,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_conflict_logged_every_day_shows_once_with_a_count() {
|
||||||
|
let items = vec![
|
||||||
|
entry(
|
||||||
|
300,
|
||||||
|
ActivityKind::Conflict,
|
||||||
|
"a (sync-conflict).txt",
|
||||||
|
"differs",
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
entry(250, ActivityKind::Upload, "b.txt", "", true),
|
||||||
|
entry(
|
||||||
|
200,
|
||||||
|
ActivityKind::Conflict,
|
||||||
|
"a (sync-conflict).txt",
|
||||||
|
"differs",
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
entry(
|
||||||
|
100,
|
||||||
|
ActivityKind::Conflict,
|
||||||
|
"a (sync-conflict).txt",
|
||||||
|
"differs",
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
let rows = collapse_feed(&items);
|
||||||
|
assert_eq!(rows.len(), 2);
|
||||||
|
assert_eq!(rows[0].entry.time, 300);
|
||||||
|
assert_eq!(rows[0].count, 3);
|
||||||
|
assert_eq!(rows[0].first, 100);
|
||||||
|
assert_eq!(rows[1].count, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn other_entries_collapse_only_when_back_to_back() {
|
||||||
|
let items = vec![
|
||||||
|
entry(300, ActivityKind::Upload, "b.txt", "timeout", false),
|
||||||
|
entry(200, ActivityKind::Upload, "b.txt", "timeout", false),
|
||||||
|
entry(150, ActivityKind::Trash, "c.txt", "", true),
|
||||||
|
entry(100, ActivityKind::Upload, "b.txt", "timeout", false),
|
||||||
|
];
|
||||||
|
let counts: Vec<usize> = collapse_feed(&items).iter().map(|r| r.count).collect();
|
||||||
|
assert_eq!(counts, vec![2, 1, 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_problems_filter_holds_failures_and_conflicts() {
|
||||||
|
let failed = entry(1, ActivityKind::Upload, "a", "", false);
|
||||||
|
let conflict = entry(1, ActivityKind::Conflict, "a", "", false);
|
||||||
|
let fine = entry(1, ActivityKind::Upload, "a", "", true);
|
||||||
|
assert!(ActivityFilter::Problems.matches(&failed));
|
||||||
|
assert!(ActivityFilter::Problems.matches(&conflict));
|
||||||
|
assert!(!ActivityFilter::Problems.matches(&fine));
|
||||||
|
assert!(ActivityFilter::Transfers.matches(&failed));
|
||||||
|
assert!(!ActivityFilter::Sharing.matches(&fine));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn days_read_today_yesterday_weekday_then_date() {
|
||||||
|
let tz = glib::TimeZone::utc();
|
||||||
|
let at = |y, m, d, h| glib::DateTime::new(&tz, y, m, d, h, 0, 0.0).unwrap();
|
||||||
|
let now = at(2026, 9, 23, 12);
|
||||||
|
assert_eq!(day_label(&at(2026, 9, 23, 1), &now), "Today");
|
||||||
|
assert_eq!(day_label(&at(2026, 9, 22, 23), &now), "Yesterday");
|
||||||
|
// Weekday and month names follow the locale, so compare against it.
|
||||||
|
let named = |dt: glib::DateTime, format: &str| dt.format(format).unwrap().to_string();
|
||||||
|
assert_eq!(
|
||||||
|
day_label(&at(2026, 9, 20, 10), &now),
|
||||||
|
named(at(2026, 9, 20, 10), "%A")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
day_label(&at(2026, 9, 1, 10), &now),
|
||||||
|
named(at(2026, 9, 1, 10), "%A, %B %-d")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
day_label(&at(2025, 12, 31, 10), &now),
|
||||||
|
named(at(2025, 12, 31, 10), "%B %-d, %Y")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -501,7 +501,7 @@ fn retry_queued(ui: &Rc<Ui>, id: Option<i64>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// How long a conflict listing stays fresh on the refresh tick.
|
/// How long a conflict listing stays fresh on the refresh tick.
|
||||||
const CONFLICTS_TTL: Duration = Duration::from_secs(30);
|
pub(crate) const CONFLICTS_TTL: Duration = Duration::from_secs(30);
|
||||||
|
|
||||||
/// Poll the conflict list while the Sync page is on screen. `force` skips the
|
/// Poll the conflict list while the Sync page is on screen. `force` skips the
|
||||||
/// TTL, for navigation and right after a resolution.
|
/// TTL, for navigation and right after a resolution.
|
||||||
@@ -572,7 +572,7 @@ fn conflict_side(size: u64, modified: i64) -> String {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn conflict_row(ui: &Rc<Ui>, conflict: &ConflictInfo) -> adw::ActionRow {
|
pub(crate) fn conflict_row(ui: &Rc<Ui>, conflict: &ConflictInfo) -> adw::ActionRow {
|
||||||
let verdict = if !conflict.original_exists {
|
let verdict = if !conflict.original_exists {
|
||||||
"The original is gone; only this copy is left".to_string()
|
"The original is gone; only this copy is left".to_string()
|
||||||
} else if conflict.identical {
|
} else if conflict.identical {
|
||||||
@@ -598,12 +598,12 @@ fn conflict_row(ui: &Rc<Ui>, conflict: &ConflictInfo) -> adw::ActionRow {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The last component of a mount-relative path.
|
/// The last component of a mount-relative path.
|
||||||
fn file_name(path: &str) -> &str {
|
pub(crate) fn file_name(path: &str) -> &str {
|
||||||
path.rsplit('/').next().unwrap_or(path)
|
path.rsplit('/').next().unwrap_or(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Ask which version of a conflicted file to keep.
|
/// Ask which version of a conflicted file to keep.
|
||||||
fn prompt_resolve_conflict(ui: &Rc<Ui>, conflict: &ConflictInfo) {
|
pub(crate) fn prompt_resolve_conflict(ui: &Rc<Ui>, conflict: &ConflictInfo) {
|
||||||
let win = ui_window(ui);
|
let win = ui_window(ui);
|
||||||
let copy_name = file_name(&conflict.path).to_string();
|
let copy_name = file_name(&conflict.path).to_string();
|
||||||
let original_name = file_name(&conflict.original_path).to_string();
|
let original_name = file_name(&conflict.original_path).to_string();
|
||||||
@@ -690,6 +690,7 @@ fn resolve_conflict(ui: &Rc<Ui>, path: String, keep: ConflictKeep) {
|
|||||||
Ok(Ok(Response::Ok { message })) => {
|
Ok(Ok(Response::Ok { message })) => {
|
||||||
toast(&ui, &capitalize(&message));
|
toast(&ui, &capitalize(&message));
|
||||||
refresh_conflicts(&ui, true);
|
refresh_conflicts(&ui, true);
|
||||||
|
refresh_activity_conflicts(&ui, true);
|
||||||
}
|
}
|
||||||
Ok(Ok(Response::Error { message, kind })) => {
|
Ok(Ok(Response::Error { message, kind })) => {
|
||||||
toast_failure(&ui, "Couldn't resolve the conflict", &message, kind)
|
toast_failure(&ui, "Couldn't resolve the conflict", &message, kind)
|
||||||
@@ -704,7 +705,7 @@ fn resolve_conflict(ui: &Rc<Ui>, path: String, keep: ConflictKeep) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// First letter upper-cased, for a daemon message used as a sentence.
|
/// First letter upper-cased, for a daemon message used as a sentence.
|
||||||
fn capitalize(message: &str) -> String {
|
pub(crate) fn capitalize(message: &str) -> String {
|
||||||
let mut chars = message.chars();
|
let mut chars = message.chars();
|
||||||
match chars.next() {
|
match chars.next() {
|
||||||
Some(first) => first.to_uppercase().chain(chars).collect(),
|
Some(first) => first.to_uppercase().chain(chars).collect(),
|
||||||
|
|||||||
@@ -103,6 +103,11 @@ Schema: **31** (unchanged).
|
|||||||
still a screen and a half from the end, with a spinner under it while a page is on its way. A
|
still a screen and a half from the end, with a spinner under it while a page is on its way. A
|
||||||
month scrubber on the right edge of the whole timeline, marked by year, jumps to the month you
|
month scrubber on the right edge of the whole timeline, marked by year, jumps to the month you
|
||||||
drag it to and follows the scroll otherwise.
|
drag it to and follows the scroll otherwise.
|
||||||
|
- **Activity log redesign.** The feed is grouped by day (Today, Yesterday, weekday, date), with
|
||||||
|
a time column and filter chips for All, Transfers, Changes, Sharing and Problems. A conflict the
|
||||||
|
daemon logs again on every run now shows once, with a count. A "Needs attention" section on top
|
||||||
|
lists the conflicts still waiting for a decision. Each logged conflict has a Resolve… button that
|
||||||
|
opens the same dialog as the Sync page, and reads as resolved once the copy is gone.
|
||||||
- **US spelling throughout.** The app, the CLI and the docs say "Favorite" and "color", matching the
|
- **US spelling throughout.** The app, the CLI and the docs say "Favorite" and "color", matching the
|
||||||
`pdfs favorite` command.
|
`pdfs favorite` command.
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user