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
+518 -80

No files matched your search

+9 -3
View File
@@ -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,
+498 -72
View File
@@ -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<Vec<gtk4::Widget>>,
pub(crate) attention: adw::PreferencesGroup,
pub(crate) attention_rows: RefCell<Vec<adw::ActionRow>>,
pub(crate) days: gtk4::Box,
pub(crate) inflight: Cell<bool>,
/// 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<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.
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<Ui>, retry: &gtk4::Button) {
pub(crate) fn wire_activity(
ui: &Rc<Ui>,
retry: &gtk4::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<Ui>) {
refresh_activity_conflicts(ui, false);
if ui.activity.inflight.get() {
return;
}
@@ -132,6 +237,7 @@ pub(crate) fn refresh_activity(ui: &Rc<Ui>) {
}
pub(crate) fn load_activity(ui: &Rc<Ui>) {
refresh_activity_conflicts(ui, true);
if ui.activity.inflight.get() {
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.
/// 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<Ui>, 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<Ui>, 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::<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,
}),
}
}
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<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(
ui,
"document-open-recent-symbolic",
@@ -232,40 +453,149 @@ pub(crate) fn repaint_activity(ui: &Rc<Ui>, items: &[ActivityEntry]) {
);
return;
}
ui.activity.content.set_visible_child_name("list");
let mut rows: Vec<gtk4::Widget> = 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<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 {
// 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<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)
.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<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.
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<Ui>, conflict: &ConflictInfo) -> adw::ActionRow {
pub(crate) fn conflict_row(ui: &Rc<Ui>, 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<Ui>, 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<Ui>, conflict: &ConflictInfo) {
pub(crate) fn prompt_resolve_conflict(ui: &Rc<Ui>, 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<Ui>, 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<Ui>, 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(),
+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
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.