diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs index b3bd6eb..d295406 100644 --- a/crates/pdfs-gui/src/app/main.rs +++ b/crates/pdfs-gui/src/app/main.rs @@ -586,10 +586,16 @@ fn build_window(app: &adw::Application) { content: activity_widgets.content.clone(), status: activity_widgets.status.clone(), retry: activity_widgets.retry.clone(), - group: activity_widgets.group.clone(), - rows: RefCell::new(Vec::new()), + attention: activity_widgets.attention.clone(), + attention_rows: RefCell::new(Vec::new()), + days: activity_widgets.days.clone(), inflight: Cell::new(false), 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 { 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_devices(&ui, &devices_widgets.retry, &devices_widgets.restore); 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_refresh( &ui, diff --git a/crates/pdfs-gui/src/app/pages/activity.rs b/crates/pdfs-gui/src/app/pages/activity.rs index 6355ac2..7391eaa 100644 --- a/crates/pdfs-gui/src/app/pages/activity.rs +++ b/crates/pdfs-gui/src/app/pages/activity.rs @@ -1,38 +1,132 @@ use crate::*; 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) status: adw::StatusPage, pub(crate) retry: gtk4::Button, - pub(crate) group: adw::PreferencesGroup, - pub(crate) rows: RefCell>, + pub(crate) attention: adw::PreferencesGroup, + pub(crate) attention_rows: RefCell>, + pub(crate) days: gtk4::Box, pub(crate) inflight: Cell, - /// Fingerprint of the feed currently on screen (see [`activity_key`]). The - /// page polls every couple of seconds and usually gets back exactly what it - /// is already showing; rebuilding all 200 rows for that would throw away the - /// user's scroll position several times a minute. + /// 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>, + /// The last feed the daemon sent, so a filter change repaints without a + /// round-trip. + pub(crate) items: RefCell>, + pub(crate) filter: Cell, + /// 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>>, + pub(crate) conflicts_inflight: Cell, + pub(crate) conflicts_at: Cell>, } /// Widgets the Activity page's load/repaint touch. pub(crate) struct ActivityWidgets { pub(crate) content: gtk4::Stack, 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) 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 -/// 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) { let refresh = refresh_button(); - let group = adw::PreferencesGroup::new(); - let clamp = adw::Clamp::builder().child(&group).build(); + // Filter chips: a linked row of radio toggles. + 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() .vexpand(true) + .hscrollbar_policy(gtk4::PolicyType::Never) .child(&clamp) .build(); @@ -56,13 +150,7 @@ pub(crate) fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) { content.add_named(&scroll, Some("list")); content.add_named(&status, Some("status")); - let inner = gtk4::Box::new(gtk4::Orientation::Vertical, 12); - 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); + let (frame, header, _) = page_frame("Activity", &content); header.pack_end(&refresh); ( @@ -70,7 +158,9 @@ pub(crate) fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) { ActivityWidgets { content, status, - group, + attention, + days, + filters, retry, refresh, }, @@ -78,12 +168,26 @@ pub(crate) fn build_activity_page() -> (gtk4::Widget, ActivityWidgets) { } /// Install the Activity page's retry button. -pub(crate) fn wire_activity(ui: &Rc, retry: >k4::Button) { +pub(crate) fn wire_activity( + ui: &Rc, + retry: >k4::Button, + filters: &[(ActivityFilter, gtk4::ToggleButton)], +) { let ui_retry = ui.clone(); retry.connect_clicked(move |_| { service::restart(); 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. @@ -109,6 +213,7 @@ pub(crate) fn activity_status( /// 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. pub(crate) fn refresh_activity(ui: &Rc) { + refresh_activity_conflicts(ui, false); if ui.activity.inflight.get() { return; } @@ -132,6 +237,7 @@ pub(crate) fn refresh_activity(ui: &Rc) { } pub(crate) fn load_activity(ui: &Rc) { + refresh_activity_conflicts(ui, true); if ui.activity.inflight.get() { return; } @@ -201,28 +307,143 @@ pub(crate) fn activity_unreachable(ui: &Rc) { }); } -/// A cheap fingerprint of a feed: how many entries, and what the newest one is. -/// The log is append-only and newest-first, so a feed with the same length and -/// the same newest entry is the same feed. -pub(crate) fn activity_key(items: &[ActivityEntry]) -> String { - match items.first() { - Some(a) => format!("{}:{}:{}:{}", items.len(), a.time, a.target, a.detail), - None => String::new(), - } -} - -/// Rebuild the Activity section from a fresh log, unless it is already showing -/// exactly this feed. -pub(crate) fn repaint_activity(ui: &Rc, items: &[ActivityEntry]) { - let key = activity_key(items); - if ui.activity.key.borrow().as_deref() == Some(key.as_str()) { +/// Poll the conflicts still waiting for a decision while the Activity page is +/// on screen. `force` skips the TTL, for navigation and right after a +/// resolution. Listing walks every node the daemon knows, so the tick asks far +/// less often than it does for the feed. +pub(crate) fn refresh_activity_conflicts(ui: &Rc, force: bool) { + let state = &ui.activity; + if state.conflicts_inflight.get() + || (!force + && state + .conflicts_at + .get() + .is_some_and(|at| at.elapsed() < CONFLICTS_TTL)) + { return; } - *ui.activity.key.borrow_mut() = Some(key); - for row in ui.activity.rows.borrow_mut().drain(..) { - ui.activity.group.remove(&row); + state.conflicts_inflight.set(true); + let rx = spawn_request(ui.dirs.control_socket(), Request::ListConflicts); + 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); + } + }); +} + +/// 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::>() + .join("\n") + }); + format!("{newest}|{filter:?}|{open:?}") +} + +/// Keep a fresh log from the daemon and repaint with it. +pub(crate) fn repaint_activity(ui: &Rc, 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> { + 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 = 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, + }), + } } - if items.is_empty() { + 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) { + 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( ui, "document-open-recent-symbolic", @@ -232,40 +453,149 @@ pub(crate) fn repaint_activity(ui: &Rc, items: &[ActivityEntry]) { ); return; } - ui.activity.content.set_visible_child_name("list"); - let mut rows: Vec = Vec::new(); - for a in items { - let mut subtitle = activity_time(a.time); - if !a.detail.is_empty() { - subtitle = format!("{subtitle} · {}", a.detail); - } - let title = match a.kind { - // The target of an empty is a count, not a name to lead with a verb. - ActivityKind::EmptyTrash => format!("Emptied Trash ({})", a.target), - kind => format!("{} {}", activity_verb(kind), a.target), - }; - let row = adw::ActionRow::builder() - .title(title) - .subtitle(subtitle) - .build(); - // A failure and a conflict are different news: a conflict kept both - // copies, a failure lost the action. Each gets its own icon and colour. - let icon = gtk4::Image::from_icon_name(if a.ok { - activity_icon(a.kind) - } else { - "dialog-error-symbolic" - }); - if !a.ok { - icon.add_css_class("error"); - } else if a.kind == ActivityKind::Conflict { - icon.add_css_class("warning"); - } - row.add_prefix(&icon); - row.set_activatable(false); - ui.activity.group.add(&row); - rows.push(row.upcast()); + *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); } - *ui.activity.rows.borrow_mut() = rows; + 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 = 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, + row: &FeedRow, + at: Option<&glib::DateTime>, + open: &[ConflictInfo], +) -> adw::ActionRow { + let a = row.entry; + let title = match a.kind { + // The target of an empty is a count, not a name to lead with a verb. + ActivityKind::EmptyTrash => format!("Emptied Trash ({})", a.target), + kind => format!("{} {}", activity_verb(kind), a.target), + }; + // 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 = 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) + .subtitle(subtitle.join(" · ")) + .use_markup(false) + .title_lines(1) + .subtitle_lines(2) + .build(); + + // A failure and a conflict are different news: a conflict kept both + // copies, a failure lost the action. Each gets its own icon and colour. + let icon = gtk4::Image::from_icon_name(if resolved { + "object-select-symbolic" + } else if a.ok || a.kind == ActivityKind::Conflict { + activity_icon(a.kind) + } else { + "dialog-error-symbolic" + }); + if resolved { + icon.add_css_class("success"); + } else if a.kind == ActivityKind::Conflict { + icon.add_css_class("warning"); + } else if !a.ok { + icon.add_css_class("error"); + } + widget.add_prefix(&icon); + + let time = gtk4::Label::new(Some( + &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)); + } + 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. @@ -313,3 +643,99 @@ pub(crate) fn activity_time(secs: i64) -> String { .map(|s| s.to_string()) .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 = 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") + ); + } +} diff --git a/crates/pdfs-gui/src/app/pages/locations.rs b/crates/pdfs-gui/src/app/pages/locations.rs index 6b251d3..a73f642 100644 --- a/crates/pdfs-gui/src/app/pages/locations.rs +++ b/crates/pdfs-gui/src/app/pages/locations.rs @@ -501,7 +501,7 @@ fn retry_queued(ui: &Rc, id: Option) { } /// 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 /// 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, conflict: &ConflictInfo) -> adw::ActionRow { +pub(crate) fn conflict_row(ui: &Rc, conflict: &ConflictInfo) -> adw::ActionRow { let verdict = if !conflict.original_exists { "The original is gone; only this copy is left".to_string() } else if conflict.identical { @@ -598,12 +598,12 @@ fn conflict_row(ui: &Rc, conflict: &ConflictInfo) -> adw::ActionRow { } /// 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) } /// Ask which version of a conflicted file to keep. -fn prompt_resolve_conflict(ui: &Rc, conflict: &ConflictInfo) { +pub(crate) fn prompt_resolve_conflict(ui: &Rc, conflict: &ConflictInfo) { let win = ui_window(ui); let copy_name = file_name(&conflict.path).to_string(); let original_name = file_name(&conflict.original_path).to_string(); @@ -690,6 +690,7 @@ fn resolve_conflict(ui: &Rc, path: String, keep: ConflictKeep) { Ok(Ok(Response::Ok { message })) => { toast(&ui, &capitalize(&message)); refresh_conflicts(&ui, true); + refresh_activity_conflicts(&ui, true); } Ok(Ok(Response::Error { message, kind })) => { toast_failure(&ui, "Couldn't resolve the conflict", &message, kind) @@ -704,7 +705,7 @@ fn resolve_conflict(ui: &Rc, path: String, keep: ConflictKeep) { } /// 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(); match chars.next() { Some(first) => first.to_uppercase().chain(chars).collect(), diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 163f049..e98d77c 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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 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. +- **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 `pdfs favorite` command.