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:
nvrl committed 2026-09-23 03:44:21 +02:00
1 parent f457fa68fc
commit fe8fb8cdcf
4 files changed
+501 -63

No files matched your search

+9 -3
View File
@@ -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,
+481 -55
View File
@@ -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: &gtk4::Button) { pub(crate) fn wire_activity(
ui: &Rc<Ui>,
retry: &gtk4::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(&section);
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")
);
}
}
+6 -5
View File
@@ -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(),
+5
View File
@@ -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.