mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 08:56:59 +01:00
feat(gui,fuse): delete photos from the gallery and notice remote deletions
The gallery could show photos and nothing else: a photo deleted on the phone stayed on the page for minutes, and a photo you wanted gone had to be deleted on another device. Both are now handled here. Deleting needs a uid-addressed request, because `Request::Delete` takes a mountpoint path and the photos volume is not in the FUSE mount. `TrashNodes` trashes through the SDK and answers per-uid outcomes, so a partial failure is reportable; the daemon then drops those rows with `Db::photos_delete`, album membership included, rather than leaving the gallery to wait for the next timeline refresh. The GUI removes the tiles immediately with an Undo toast and puts back anything the server refused — the photos are in Proton's trash and stay recoverable there either way. Selection is Ctrl/Shift+click, the Select toggle or Delete in the lightbox. Remote deletions arrive on their own now. The Drive volume and the photos volume have separate event streams, so the photos volume gets its own poller and its own cursor. Trash and delete events remove rows; anything else only marks the timeline stale, because there is no inode space to converge on this volume. With the timeline TTL down to 60 s and an explicit Refresh that waits for the server instead of only clearing the freshness stamp, a photo deleted elsewhere leaves the grid in about ten seconds.
This commit is contained in:
1 parent
5a8e73657c
commit
c6bdba024f
13 files changed
+724
-10
No files matched your search
@@ -93,6 +93,13 @@ pub enum Request {
|
||||
/// already cached (one batched round-trip) and replying with their on-disk
|
||||
/// paths. Keep the batch small — it is served on demand, as tiles scroll in.
|
||||
PhotoThumbs { uids: Vec<String> },
|
||||
/// Trash photos by node uid, answered with [`Response::Trashed`].
|
||||
///
|
||||
/// Uid-addressed rather than path-addressed because the photos volume is not
|
||||
/// part of the FUSE mount: the gallery knows a photo only by its uid, and
|
||||
/// [`Request::Delete`] has no path to take for it. The nodes land in Proton
|
||||
/// trash, so this stays recoverable from the Trash page.
|
||||
TrashNodes { uids: Vec<String> },
|
||||
/// Fetch thumbnails for ordinary Drive image files shown outside the Photos
|
||||
/// timeline. The modification time is the cache validity tag, so replacing
|
||||
/// an image can never reuse its previous revision's thumbnail.
|
||||
@@ -1288,6 +1295,13 @@ fn default_photo_kind() -> PhotoKind {
|
||||
PhotoKind::Photo
|
||||
}
|
||||
|
||||
/// One node a [`Request::TrashNodes`] batch could not trash, and why.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct TrashFailure {
|
||||
pub uid: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// One thumbnail in a [`Response::Thumbs`] batch.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct PhotoThumb {
|
||||
@@ -1587,6 +1601,14 @@ pub enum Response {
|
||||
PhotoMonths { months: Vec<PhotoMonth> },
|
||||
/// Thumbnails for a [`Request::PhotoThumbs`] batch.
|
||||
Thumbs { items: Vec<PhotoThumb> },
|
||||
/// Per-node outcome of a [`Request::TrashNodes`] batch. Reported per uid
|
||||
/// rather than as one verdict: the server answers each node separately, and
|
||||
/// a front-end that removed all of them on a partial success would show a
|
||||
/// photo as gone that is still there.
|
||||
Trashed {
|
||||
trashed: Vec<String>,
|
||||
failed: Vec<TrashFailure>,
|
||||
},
|
||||
/// A [`Request::FileThumbs`] generation is no longer current. This is
|
||||
/// retryable after reserving a fresh generation and must never be interpreted
|
||||
/// as a permanent "no thumbnail" verdict.
|
||||
@@ -1925,6 +1947,36 @@ mod tests {
|
||||
assert!(items[0].name.is_empty());
|
||||
}
|
||||
|
||||
/// The gallery trashes by uid, and reports per-uid outcomes back: a partial
|
||||
/// failure has to survive the wire so the GUI can put those tiles back.
|
||||
#[test]
|
||||
fn trash_requests_and_outcomes_roundtrip() {
|
||||
let request = Request::TrashNodes {
|
||||
uids: vec!["volume~first".into(), "volume~second".into()],
|
||||
};
|
||||
let line = serde_json::to_string(&request).unwrap();
|
||||
assert!(!line.contains('\n'), "wire form must be a single line");
|
||||
let decoded: Request = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!(line, serde_json::to_string(&decoded).unwrap());
|
||||
|
||||
let response = Response::Trashed {
|
||||
trashed: vec!["volume~first".into()],
|
||||
failed: vec![TrashFailure {
|
||||
uid: "volume~second".into(),
|
||||
message: "permission denied".into(),
|
||||
}],
|
||||
};
|
||||
let line = serde_json::to_string(&response).unwrap();
|
||||
let decoded: Response = serde_json::from_str(&line).unwrap();
|
||||
let Response::Trashed { trashed, failed } = decoded else {
|
||||
panic!("decoded the wrong response variant");
|
||||
};
|
||||
assert_eq!(trashed, ["volume~first"]);
|
||||
assert_eq!(failed.len(), 1);
|
||||
assert_eq!(failed[0].uid, "volume~second");
|
||||
assert_eq!(failed[0].message, "permission denied");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thumbnail_build_requests_and_progress_roundtrip() {
|
||||
let requests = [
|
||||
|
||||
@@ -102,6 +102,31 @@ impl Db {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Forget photos that have been trashed, so the gallery does not show them
|
||||
/// until the next timeline refresh would have caught up.
|
||||
///
|
||||
/// Album membership goes with them: a trashed photo is gone from every album
|
||||
/// that held it, and leaving the rows behind would keep it on the album
|
||||
/// pages and in the album covers.
|
||||
pub fn photos_delete(&self, uids: &[String]) -> Result<usize> {
|
||||
if uids.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
let mut removed = 0;
|
||||
{
|
||||
let mut photos = tx.prepare("DELETE FROM photos WHERE uid = ?1")?;
|
||||
let mut albums = tx.prepare("DELETE FROM album_photos WHERE uid = ?1")?;
|
||||
for uid in uids {
|
||||
removed += photos.execute([uid])?;
|
||||
albums.execute([uid])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
/// One page of the persisted timeline, newest first. `kind`, when set,
|
||||
/// restricts the page to one tab (Photos / Videos / Raw); `range`, when set,
|
||||
/// restricts it to a `[from, to)` capture-time window (epoch seconds) — the
|
||||
|
||||
@@ -3919,3 +3919,54 @@ fn an_idle_worker_does_not_wait_on_an_op_another_worker_has() {
|
||||
"an op somebody else is draining is not work this worker is waiting for"
|
||||
);
|
||||
}
|
||||
|
||||
/// Trashing a photo from the gallery must not leave it behind on an album page:
|
||||
/// the row goes, its album membership goes with it, and nothing else moves.
|
||||
#[test]
|
||||
fn photos_delete_removes_the_photo_and_its_album_membership() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
db.photos_replace(&[
|
||||
("p1".into(), 300, None, None, None),
|
||||
("p2".into(), 200, None, None, None),
|
||||
("p3".into(), 100, None, None, None),
|
||||
])
|
||||
.unwrap();
|
||||
db.albums_replace(&[StoredAlbum {
|
||||
uid: "a1".into(),
|
||||
name: "Trip".into(),
|
||||
photo_count: 3,
|
||||
cover_uid: Some("p1".into()),
|
||||
last_activity: Some(500),
|
||||
shared: false,
|
||||
}])
|
||||
.unwrap();
|
||||
db.album_photos_replace(
|
||||
"a1",
|
||||
&[
|
||||
("p1".into(), 300, None, None),
|
||||
("p2".into(), 200, None, None),
|
||||
("p3".into(), 100, None, None),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(db.photos_delete(&["p2".into()]).unwrap(), 1);
|
||||
|
||||
let page = db.photos_page(0, 10, None, None, false).unwrap();
|
||||
assert_eq!(
|
||||
page.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||
["p1", "p3"],
|
||||
"only the trashed photo leaves the timeline"
|
||||
);
|
||||
assert_eq!(db.album_photos_count("a1").unwrap(), 2);
|
||||
let album = db.album_photos_page("a1", 0, 10).unwrap();
|
||||
assert_eq!(
|
||||
album.iter().map(|p| p.uid.as_str()).collect::<Vec<_>>(),
|
||||
["p1", "p3"]
|
||||
);
|
||||
|
||||
// A uid we no longer hold is not an error: the event poller and the trash
|
||||
// reply can both report the same deletion.
|
||||
assert_eq!(db.photos_delete(&["p2".into()]).unwrap(), 0);
|
||||
assert_eq!(db.photos_delete(&[]).unwrap(), 0);
|
||||
}
|
||||
Reference in new issue
Block a user