feat(core,fuse,gui): show a sync issue on its node in the file browser

A refused change was only on the Sync page. A folder listing now carries
each node's issue (by local id, so a file not yet on Drive has it too),
and the browser's badge ranks it first, folders included, with the
issue's explanation as its tooltip. The event sampler publishes Files
when the set of issues changes, so the badge comes and goes with it.
This commit is contained in:
nvrl committed 2026-10-05 01:06:03 +02:00
1 parent 9dfe4c5450
commit a8ed6060af
25 files changed
+390 -251

No files matched your search

+1
View File
@@ -3554,6 +3554,7 @@ mod login_wait_tests {
shared_by_unverified: false,
trashed_at: 0,
trashed_from: None,
issue: None,
};
assert_eq!(shared_by_line(&entry), None, "no invitation, no line");
entry.shared_by = "alice@proton.me".into();
+4
View File
@@ -1296,6 +1296,10 @@ pub struct DirEntry {
/// not known, and for anything not in the trash.
#[serde(default)]
pub trashed_from: Option<String>,
/// Why Drive refused a change queued on the node, for a [`Request::ListDir`]
/// entry; `None` otherwise.
#[serde(default)]
pub issue: Option<SyncIssue>,
}
/// One hit in a [`Request::Search`] result. Like [`DirEntry`] but carries the
+20
View File
@@ -1371,6 +1371,26 @@ impl Db {
Ok(rows)
}
/// The issue each node with a refused change has, by local id: what the
/// file browser shows on the node itself.
pub fn node_issues(&self) -> Result<HashMap<i64, String>> {
let conn = self.read();
let mut stmt = conn.prepare(
"SELECT COALESCE(o.lid, n.lid), o.issue FROM pending_op o
LEFT JOIN nodes n ON o.lid IS NULL AND n.uid = o.uid
WHERE o.issue IS NOT NULL",
)?;
let rows = stmt
.query_map([], |r| Ok((r.get::<_, Option<i64>>(0)?, r.get(1)?)))?
.filter_map(|row| match row {
Ok((Some(lid), issue)) => Some(Ok((lid, issue))),
Ok((None, _)) => None,
Err(e) => Some(Err(e)),
})
.collect::<rusqlite::Result<_>>()?;
Ok(rows)
}
/// Make a create due again if it was renamed or moved
/// ([`Db::rewrite_op_target`]) while the attempt that just failed was on
/// the wire, `parent_uid` and `name` being what that attempt sent. What it
+24
View File
@@ -6988,6 +6988,30 @@ fn an_op_with_an_issue_counts_as_failing_at_once() {
assert_eq!(db.pending_ops().unwrap().len(), 2);
}
/// The browser shows an issue on the node it is about, which it knows by
/// local id, under either uid.
#[test]
fn a_refused_change_names_its_node_by_local_id() {
let db = Db::open_in_memory().unwrap();
db.upsert_nodes(&[
folder("root", None, "My Files"),
file("a", "root", "a.bin", 1),
file("b", "root", "b.bin", 1),
])
.unwrap();
let refused = queued_op(&db, &uid("a").to_string(), 1, 90_000);
queued_op(&db, &uid("b").to_string(), 1, 90_000);
let gone = queued_op(&db, "vol~nowhere", 1, 90_000);
db.set_op_issue(refused, Some("quota")).unwrap();
db.set_op_issue(gone, Some("missing")).unwrap();
let lid = db.lid_of(&uid("a").to_string()).unwrap().unwrap();
assert_eq!(
db.node_issues().unwrap(),
std::collections::HashMap::from([(lid, "quota".to_string())])
);
}
#[test]
fn retry_all_touches_only_ops_that_have_failed() {
let db = Db::open_in_memory().unwrap();
+15 -3
View File
@@ -9,7 +9,7 @@
//! through one place (the status, transfers, sync progress) and publishes them
//! when they differ.
use std::collections::BTreeSet;
use std::collections::{BTreeSet, HashMap};
use std::sync::Arc;
use std::time::Duration;
@@ -153,13 +153,15 @@ pub(crate) fn transfers_line(items: Vec<TransferItem>, jobs: Vec<JobItem>) -> Op
/// Watch what changes without a single place to publish it from, and publish it
/// when it differs from the last look: the status (queue counts, cache use,
/// online, pause), the transfers snapshot, and the synced folders with their
/// live progress. Looks only while someone is subscribed.
/// online, pause), the transfers snapshot, the synced folders with their
/// live progress, and which nodes have a sync issue. Looks only while someone
/// is subscribed.
pub(crate) fn run_event_sampler(core: Core, username: String, mountpoint: std::path::PathBuf) {
let mut transfers: Option<Arc<str>> = None;
let mut status: Option<String> = None;
let mut queue: Option<String> = None;
let mut locations: Option<String> = None;
let mut issues: Option<HashMap<i64, String>> = None;
let mut tick = 0u32;
while core.shutdown.sleep(SAMPLE_INTERVAL) {
if !core.events.has_subscribers() {
@@ -169,6 +171,7 @@ pub(crate) fn run_event_sampler(core: Core, username: String, mountpoint: std::p
status = None;
queue = None;
locations = None;
issues = None;
continue;
}
let line = transfers_line(core.transfers.snapshot(), core.jobs_snapshot());
@@ -196,6 +199,15 @@ pub(crate) fn run_event_sampler(core: Core, username: String, mountpoint: std::p
}
queue = Some(queue_key);
// An issue shows on its node in the file browser: raised, cleared,
// landed or discarded, the listing is out of date.
if let Ok(now) = core.db.node_issues() {
if issues.as_ref().is_some_and(|last| *last != now) {
core.events.publish(&[Topic::Files]);
}
issues = Some(now);
}
if let Ok(now) = core.list_locations() {
let key = serde_json::to_string(&now).unwrap_or_default();
if locations.as_ref().is_some_and(|last| *last != key) {
+11 -5
View File
@@ -59,7 +59,7 @@ use pdfs_core::cache::{BLOCK_SIZE, Baseline, BlockGeometry, BlockSpan, ContentCa
use pdfs_core::config::{AppDirs, SweepMode};
use pdfs_core::control::{
ActivityEntry, ActivityKind, DirEntry, ErrorKind, LocalHit, PhotoKind, PublicLinkInfo,
SearchFilters, SearchHit, SearchSource, SyncFolderInfo, SyncPhase, SyncProgress,
SearchFilters, SearchHit, SearchSource, SyncFolderInfo, SyncIssue, SyncPhase, SyncProgress,
ThumbnailBuildStatus, Topic, TransferDirection,
};
use pdfs_core::db::{
@@ -4324,29 +4324,31 @@ impl Core {
self.settle_listing_sizes(ino);
// Snapshot the listing, then drop the lock before touching the on-disk
// pin registry so a slow disk read doesn't block FUSE metadata ops.
let rows: Vec<(String, bool, u64, i64, NodeUid)> = {
let rows: Vec<(String, bool, u64, i64, NodeUid, u64)> = {
let st = self.state();
st.children
.get(&ino)
.map(|kids| {
kids.iter()
.filter_map(|k| st.entries.get(k))
.map(|e| {
.filter_map(|&k| st.entries.get(&k).map(|e| (k, e)))
.map(|(k, e)| {
(
e.node.name.clone(),
e.node.is_folder(),
node_size(&e.node),
e.node.modification_time,
e.uid.clone(),
k,
)
})
.collect()
})
.unwrap_or_default()
};
let issues = self.db.node_issues().unwrap_or_default();
Ok(rows
.into_iter()
.map(|(name, is_dir, size, modified, uid)| DirEntry {
.map(|(name, is_dir, size, modified, uid, ino)| DirEntry {
name,
is_dir,
size,
@@ -4365,6 +4367,9 @@ impl Core {
shared_by_unverified: false,
trashed_at: 0,
trashed_from: None,
issue: state::ino_lid(ino)
.and_then(|lid| issues.get(&lid))
.map(|issue| SyncIssue::parse(issue)),
})
.collect())
}
@@ -5247,6 +5252,7 @@ impl Core {
shared_by: String::new(),
shared_at: 0,
shared_by_unverified: false,
issue: None,
})
.collect())
}
+1
View File
@@ -75,6 +75,7 @@ fn shared_entry_of(core: &Core, n: Node) -> DirEntry {
shared_by_unverified,
trashed_at: 0,
trashed_from: None,
issue: None,
}
}
+42 -1
View File
@@ -278,7 +278,7 @@ pub(crate) fn wait_until(limit: Duration, mut done: impl FnMut() -> bool) -> boo
mod tests {
use super::*;
use crate::sim::fake_drive::{Entry, FakeDrive, Faults};
use pdfs_core::control::RefreshScope;
use pdfs_core::control::{RefreshScope, SyncIssue};
use std::collections::BTreeMap;
#[test]
@@ -698,6 +698,47 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_refused_change_shows_on_its_node_until_it_is_discarded() {
let drive = FakeDrive::new();
let dir = scratch("issue-on-node");
let daemon = Daemon::start(&dir, drive.client(1, Faults::lan())).unwrap();
let mnt = daemon.mountpoint.clone();
drive.set_quota(10);
std::fs::write(mnt.join("big.bin"), [0u8; 64]).unwrap();
std::fs::write(mnt.join("small.txt"), b"ok").unwrap();
let list = || {
let path = mnt.to_string_lossy().into_owned();
match daemon.request(&Request::ListDir { path }) {
Ok(Response::Entries { entries }) => entries
.into_iter()
.filter(|entry| entry.name.contains('.'))
.map(|entry| (entry.name, entry.issue))
.collect::<BTreeMap<_, _>>(),
other => panic!("the daemon did not list the root: {other:?}"),
}
};
let refused = BTreeMap::from([
("big.bin".to_string(), Some(SyncIssue::Quota)),
("small.txt".to_string(), None),
]);
assert!(
wait_until(Duration::from_secs(30), || list() == refused),
"{:?} {:?}",
list(),
daemon.pending()
);
let op = daemon.pending().unwrap().remove(0);
let reply = daemon.request(&Request::DiscardPendingOp { id: op.id });
assert!(matches!(reply, Ok(Response::Ok { .. })), "{reply:?}");
assert_eq!(list(), BTreeMap::from([("small.txt".to_string(), None)]));
assert!(matches!(daemon.stop(), Ok(MountOutcome::Shutdown)));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
#[ignore = "mounts FUSE: run with `cargo test -p pdfs-fuse sim:: -- --ignored`"]
fn a_folder_whose_mkdir_is_queued_lists_after_a_refresh() {
+1
View File
@@ -46,6 +46,7 @@
.badge-pinned { color: var(--success-color); }
.badge-cached { color: var(--success-color); }
.badge-cloud { color: color-mix(in srgb, currentColor 55%, transparent); }
.badge-issue { color: var(--error-color); }
.browser-statusbar { background-color: alpha(currentColor, 0.025); }
scale.browser-status-meter trough { min-width: 104px; min-height: 6px; }
.bulk-bar { padding: 6px 8px; border-radius: 12px; background: color-mix(in srgb, var(--accent-bg-color) 12%, transparent); }
+26 -4
View File
@@ -2495,9 +2495,13 @@ pub(crate) fn start_upload(ui: &Rc<Ui>, sources: Vec<String>) {
}
/// The sync-state badge for an entry: `(icon, css-class)`, or `None` for folders
/// (which carry no per-file cache state). Pinned (kept offline) ranks above merely
/// (which carry no per-file cache state). A sync issue ranks first, on folders
/// too: the change waits for the user. Pinned (kept offline) ranks above merely
/// cached (downloaded, evictable); everything else is online-only.
pub(crate) fn badge_for(entry: &DirEntry) -> Option<(&'static str, &'static str)> {
if entry.issue.is_some() {
return Some(("dialog-warning-symbolic", "badge-issue"));
}
if entry.is_dir {
return None;
}
@@ -2513,9 +2517,11 @@ pub(crate) fn badge_for(entry: &DirEntry) -> Option<(&'static str, &'static str)
/// Paint `badge` to reflect the entry's sync state (see [`badge_for`]). Clears any
/// prior colour class first, since list factories recycle cells.
pub(crate) fn apply_badge(badge: &gtk4::Image, entry: &DirEntry) {
for class in ["badge-pinned", "badge-cached", "badge-cloud"] {
for class in ["badge-pinned", "badge-cached", "badge-cloud", "badge-issue"] {
badge.remove_css_class(class);
}
let issue = entry.issue.map(super::locations::issue_text);
badge.set_tooltip_text(issue.as_deref());
match badge_for(entry) {
Some((icon, class)) => {
badge.set_icon_name(Some(icon));
@@ -2915,6 +2921,7 @@ pub(crate) fn repaint_search(ui: &Rc<Ui>, hits: &[SearchHit]) {
shared_by_unverified: false,
trashed_at: 0,
trashed_from: None,
issue: None,
})
.collect();
sort_entries(&mut entries, ui.browser.view.get());
@@ -2952,11 +2959,11 @@ fn listing_summary(files: usize, folders: usize, file_bytes: u64) -> String {
#[cfg(test)]
mod tests {
use super::{
MoveTarget, hit_location, listing_summary, move_target_allowed,
MoveTarget, badge_for, hit_location, listing_summary, move_target_allowed,
show_thumbnail_build_progress, sort_entries,
};
use pdfs_core::config::{FileSort, FilesView};
use pdfs_core::control::{DirEntry, ThumbnailBuildStatus};
use pdfs_core::control::{DirEntry, SyncIssue, ThumbnailBuildStatus};
fn entry(name: &str, is_dir: bool, size: u64, modified: i64) -> DirEntry {
DirEntry {
@@ -2974,6 +2981,7 @@ mod tests {
shared_by_unverified: false,
trashed_at: 0,
trashed_from: None,
issue: None,
}
}
@@ -2981,6 +2989,20 @@ mod tests {
entries.iter().map(|e| e.name.as_str()).collect()
}
#[test]
fn a_sync_issue_shows_on_the_node_before_its_cache_state() {
let mut file = entry("a.txt", false, 1, 0);
file.pinned = true;
assert_eq!(badge_for(&file).unwrap().1, "badge-pinned");
file.issue = Some(SyncIssue::Quota);
assert_eq!(badge_for(&file).unwrap().1, "badge-issue");
let mut folder = entry("d", true, 0, 0);
assert_eq!(badge_for(&folder), None);
folder.issue = Some(SyncIssue::Missing);
assert_eq!(badge_for(&folder).unwrap().1, "badge-issue");
}
#[test]
fn a_search_hit_location_is_its_parent_folder() {
assert_eq!(hit_location("Work/Old/a.txt"), "Work/Old");
+1 -1
View File
@@ -727,7 +727,7 @@ fn queue_row(ui: &Rc<Ui>, op: &PendingOpInfo, now: i64) -> adw::ActionRow {
/// What a sync issue means and what the user can do about it. The change stays
/// queued either way.
fn issue_text(issue: SyncIssue) -> String {
pub(crate) fn issue_text(issue: SyncIssue) -> String {
match issue {
SyncIssue::Quota => gettext(
"Your Proton Drive storage is full. Free up space or upgrade your plan, and this change uploads on its own.",
@@ -618,6 +618,7 @@ pub(crate) fn shared_item_as_entry(item: &SharedItem) -> DirEntry {
shared_by_unverified: false,
trashed_at: 0,
trashed_from: None,
issue: None,
}
}
+1
View File
@@ -533,6 +533,7 @@ mod tests {
shared_by_unverified: false,
trashed_at,
trashed_from: from.map(str::to_string),
issue: None,
}
}