mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
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:
1 parent
9dfe4c5450
commit
a8ed6060af
25 files changed
+390
-251
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -75,6 +75,7 @@ fn shared_entry_of(core: &Core, n: Node) -> DirEntry {
|
||||
shared_by_unverified,
|
||||
trashed_at: 0,
|
||||
trashed_from: None,
|
||||
issue: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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: >k4::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");
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -533,6 +533,7 @@ mod tests {
|
||||
shared_by_unverified: false,
|
||||
trashed_at,
|
||||
trashed_from: from.map(str::to_string),
|
||||
issue: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user