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:
nvrl committed 2026-09-19 15:25:04 +02:00
1 parent 5a8e73657c
commit c6bdba024f
13 files changed
+724 -10

No files matched your search

+52
View File
@@ -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 = [
+25
View File
@@ -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
+51
View File
@@ -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);
}
+107
View File
@@ -425,6 +425,113 @@ pub(super) async fn run_event_sync(
}
}
/// The state key holding the photos volume's event cursor. Separate from the
/// Drive cursor because the two volumes have their own event streams, and one
/// cursor cannot address both.
const PHOTOS_EVENT_CURSOR: &str = "photos_event_cursor";
/// Poll the photos volume's event stream, so a photo deleted or added on another
/// device shows up in the gallery within one poll instead of on the timeline's
/// freshness window.
///
/// Deliberately much smaller than [`run_event_sync`]: the photos volume is not
/// part of the FUSE mount, so there is no inode space to converge and nothing to
/// invalidate in the kernel. A trash or a delete removes the rows behind the
/// gallery; anything else only drops the timeline's freshness stamp, and the
/// next read re-enumerates. Getting that wrong costs a stale tile, not a wrong
/// file.
pub(super) async fn run_photos_event_sync(core: Core) {
let photos = core.photos();
// The photos volume only exists once the user has used Photos at all, and
// the daemon may well start before the network does.
let scope = loop {
match photos.get_photos_root().await {
Ok(Some(root)) => break root.tree_event_scope_id(),
Ok(None) => {
debug!("no photos volume; photo event sync not started");
return;
}
Err(error) => {
warn!(%error, "reading the photos root failed; retrying");
tokio::time::sleep(ONLINE_PROBE_MAX).await;
}
}
};
let mut cursor: Option<DriveEventId> = core
.db
.state_str(PHOTOS_EVENT_CURSOR)
.ok()
.flatten()
.map(DriveEventId::from);
info!(?cursor, "photo event sync started");
loop {
tokio::time::sleep(POLL_INTERVAL).await;
let events = match photos.enumerate_events(&scope, cursor.as_ref()).await {
Ok(events) => events,
Err(error) => {
warn!(%error, "photo event poll failed; retrying after interval");
continue;
}
};
let Some(head) = events.last().map(|event| event.id().clone()) else {
continue;
};
apply_photo_events(&core, &events);
// One cursor write per batch, after the batch is applied: a crash in
// between replays events whose effects are idempotent.
if let Err(error) = core.db.set_state_str(PHOTOS_EVENT_CURSOR, head.as_str()) {
warn!(%error, "persisting the photo event cursor failed");
continue;
}
cursor = Some(head);
}
}
/// Apply one batch of photos-volume events to the gallery's tables.
fn apply_photo_events(core: &Core, events: &[DriveEvent]) {
let mut gone: Vec<String> = Vec::new();
let mut refresh = false;
for event in events {
match event {
DriveEvent::NodeDeleted { node_uid, .. } => gone.push(node_uid.to_string()),
DriveEvent::NodeUpdated {
node_uid,
is_trashed,
..
} => {
if *is_trashed {
gone.push(node_uid.to_string());
} else {
// An untrash, a rename, a new upload from the phone: all of
// them need the timeline itself, which only a refresh has.
refresh = true;
}
}
// Nothing here can be applied event by event, so the whole timeline
// is re-read instead.
DriveEvent::ContinuityLost { .. } | DriveEvent::ScopeAccessLost { .. } => {
refresh = true;
}
DriveEvent::CursorAdvanced { .. } | DriveEvent::SharedWithMeUpdated { .. } => {}
}
}
if !gone.is_empty() {
match core.db.photos_delete(&gone) {
Ok(removed) if removed > 0 => {
debug!(removed, "photos removed by a remote event");
}
Ok(_) => {}
Err(error) => warn!(%error, "removing remotely deleted photos failed"),
}
}
if refresh || !gone.is_empty() {
core.invalidate_photos();
core.invalidate_albums();
}
}
/// Keep the local-file index fresh for the launcher prompt's "This computer"
/// results. Rebuilds the index whenever it is older than [`LOCAL_INDEX_TTL`],
/// then sleeps; runs on its own thread for the life of the daemon.
+19 -2
View File
@@ -18,7 +18,7 @@ use std::time::{Duration, Instant};
use pdfs_core::config::AppDirs;
use pdfs_core::control::{
ActivityKind, PhotoMonth, RefreshScope, Request as CtlRequest, Response as CtlResponse,
TransferDirection,
TransferDirection, TrashFailure,
};
use pdfs_core::{CoreError, CoreResult};
use proton_drive_rs::proton_sdk::ids::NodeUid;
@@ -260,7 +260,11 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
// Whatever changed the timeline can just as easily have
// changed an album; one scope covers the whole photos view.
core.invalidate_albums();
Ok(())
// Clearing the stamp alone only promises the *next* read
// will refetch, which means pressing Refresh paints the
// stale page and corrects it a moment later. The user
// pressed a button to see the truth, so this waits for it.
core.rt.block_on(core.refresh_timeline()).map(|_| ())
}
};
match result {
@@ -342,6 +346,19 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
items: core.photo_thumbs(&parsed),
}
}
Ok(CtlRequest::TrashNodes { uids }) => match Core::parse_uids(&uids) {
Ok(parsed) => match core.trash_photos(&parsed) {
Ok((trashed, failed)) => CtlResponse::Trashed {
trashed,
failed: failed
.into_iter()
.map(|(uid, message)| TrashFailure { uid, message })
.collect(),
},
Err(e) => CtlResponse::error(e),
},
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::FileThumbs { items, generation }) => {
match core.file_thumbs(&items, generation) {
Some(items) => CtlResponse::Thumbs { items },
+6 -2
View File
@@ -105,7 +105,7 @@ mod transfers;
mod upload;
mod r#virtual;
mod workers;
use background::{run_event_sync, run_local_index};
use background::{run_event_sync, run_local_index, run_photos_event_sync};
pub(crate) use mount::is_stale_mount;
pub use mount::{MountOptions, MountOutcome, mount};
use mount::{
@@ -203,7 +203,11 @@ const ONLINE_PROBE_MAX: Duration = Duration::from_secs(300);
/// revalidates it. The SDK hands back the whole timeline at once, so it is stored
/// in the DB and every page is sliced from there; a stale one is still served
/// immediately and refreshed in the background.
const TIMELINE_TTL: Duration = Duration::from_secs(5 * 60);
///
/// Short, because the cost of a stale timeline is a photo the user deleted on
/// their phone still sitting in the gallery, and the cost of the check is one
/// listing call that only runs when somebody is actually looking at the page.
const TIMELINE_TTL: Duration = Duration::from_secs(60);
/// How many photo nodes are resolved per [`ProtonPhotosClient::enumerate_nodes`]
/// call when enriching a refreshed timeline with names and media types (for the
/// Photos / Videos / Raw split). Batched so a large library is a handful of
+3
View File
@@ -591,6 +591,9 @@ pub fn mount(
}
};
rt.spawn(run_event_sync(client, scope, core.clone()));
// The photos volume has its own event stream; without it the gallery only
// learns about a phone-side deletion when the timeline goes stale.
rt.spawn(run_photos_event_sync(core.clone()));
if online {
let repair_core = core.clone();
let root_uid = core.primary_root_uid.clone();
+51
View File
@@ -402,6 +402,10 @@ fn extract_raw_preview(
.map(|preview| (preview, orientation)))
}
/// What a [`Core::trash_photos`] batch did: the uids that left the server, and
/// the ones that did not with the reason each was refused.
pub(crate) type TrashOutcome = (Vec<String>, Vec<(String, String)>);
/// Formats supported by the local thumbnail decoder and exposed by the GUI.
impl Core {
pub(crate) fn photos_timeline(
@@ -489,6 +493,53 @@ impl Core {
Ok(())
}
/// Trash photos by uid, and forget them locally.
///
/// The gallery is not part of the FUSE mount, so this is the photos-side
/// counterpart of [`Core::delete`]: same destination (Proton trash, so the
/// Trash page can restore them), addressed by uid because that is all a tile
/// has.
///
/// The server answers per node, and so does this: a batch where one photo
/// fails still removes the ones that went, and the caller is told which is
/// which. Only what actually left the server is forgotten locally.
pub(crate) fn trash_photos(&self, uids: &[NodeUid]) -> CoreResult<TrashOutcome> {
if uids.is_empty() {
return Ok((Vec::new(), Vec::new()));
}
for uid in uids {
self.require_uid_writable(uid)
.map_err(|errno| self.errno_error(errno, "trash access"))?;
}
let outcomes = self
.rt
.block_on(self.client.trash_nodes(uids))
.map_err(|e| CoreError::from_api(&e, "trash"))?;
let (trashed, failed) = pdfs_core::batch::split(outcomes);
let trashed: Vec<String> = trashed.iter().map(|uid| uid.to_string()).collect();
for uid in &trashed {
if let Some(parsed) = parse_uid(uid) {
self.cache.evict(&parsed);
self.evict_reader(&parsed);
}
}
// The timeline is served from the database, so removing the rows is what
// makes the photos disappear now rather than at the next refresh.
if let Err(error) = self.db.photos_delete(&trashed) {
warn!(%error, "trashed photos could not be removed from the timeline");
}
self.invalidate_photos();
self.invalidate_albums();
self.invalidate_trash();
let failed = failed
.into_iter()
.map(|(uid, error)| (uid.to_string(), error.to_string()))
.collect();
Ok((trashed, failed))
}
/// Thumbnails for `uids`, served from the cache, fetched from the server for
/// whatever is missing, and — for the photos the server has no thumbnail for
/// at all — *generated locally* from the full file (see
+15 -1
View File
@@ -258,6 +258,9 @@ fn load_proton_theme() {
.photo-tile:focus {{ outline: 2px solid {PROTON_PURPLE}; outline-offset: -2px; }}\n\
.photo-thumb {{ transition: transform 220ms ease; }}\n\
.photo-tile:hover .photo-thumb {{ transform: scale(1.06); }}\n\
.photo-check {{ color: white; background: rgba(0, 0, 0, 0.5); border-radius: 999px; padding: 3px; }}\n\
.photo-check-on {{ background: {PROTON_PURPLE}; }}\n\
.photo-tile-selected {{ outline: 3px solid {PROTON_PURPLE}; outline-offset: -3px; }}\n\
.photo-placeholder {{ color: alpha(currentColor, 0.35); background: alpha(currentColor, 0.07); }}\n\
.photo-caption {{ font-size: 0.78rem; color: white; text-shadow: 0 1px 3px rgba(0, 0, 0, 0.9); padding: 22px 10px 6px 10px; opacity: 0; transition: opacity 180ms ease; }}\n\
.photo-video-badge {{ color: white; background: rgba(0, 0, 0, 0.45); border-radius: 999px; padding: 8px; min-width: 20px; min-height: 20px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); transition: background 160ms ease; }}\n\
@@ -506,6 +509,12 @@ fn build_window(app: &adw::Application) {
relayout_source: RefCell::new(None),
bound: RefCell::new(BTreeMap::new()),
list: gallery_widgets.list.clone(),
selecting: Cell::new(false),
selected: RefCell::new(HashSet::new()),
select_btn: gallery_widgets.select_btn.clone(),
select_bar: gallery_widgets.select_bar.clone(),
select_label: gallery_widgets.select_label.clone(),
select_trash: gallery_widgets.select_trash.clone(),
},
shared: SharedState {
content: shared_widgets.content.clone(),
@@ -603,7 +612,12 @@ fn build_window(app: &adw::Application) {
);
wire_details(&ui);
wire_search(&ui);
wire_gallery(&ui, &gallery_widgets.list, &gallery_widgets.scroll);
wire_gallery(
&ui,
&gallery_widgets.list,
&gallery_widgets.scroll,
&gallery_widgets.select_done,
);
wire_gallery_empty(
&ui,
&gallery_widgets.empty_upload,
@@ -504,6 +504,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
favorite_btn.add_css_class("viewer-action-btn");
let download_btn = action("document-save-symbolic", "Save a copy…");
let delete_btn = action("user-trash-symbolic", "Move to Trash (Delete)");
let open_ext_btn = action("document-open-symbolic", "Open with another app");
let close_btn = action("window-close-symbolic", "Close (Esc)");
close_btn.add_css_class("viewer-close-btn");
@@ -516,6 +517,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
top_bar.append(&favorite_btn);
top_bar.append(&info_toggle);
top_bar.append(&download_btn);
top_bar.append(&delete_btn);
top_bar.append(&open_ext_btn);
top_bar.append(&close_btn);
overlay.add_overlay(&top_bar);
@@ -695,6 +697,18 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
});
});
// Deleting the photo on screen closes the lightbox: what it was showing is
// gone, and leaving it open on a trashed photo invites a second Delete on
// something the user cannot see.
let ui_delete = ui.clone();
let viewer_delete = viewer.clone();
let w_delete = window.clone();
delete_btn.connect_clicked(move |_| {
let uid = viewer_delete.uid.borrow().clone();
w_delete.close();
trash_photos(&ui_delete, vec![uid]);
});
let w_download = window.clone();
let viewer_download = viewer.clone();
download_btn.connect_clicked(move |_| {
@@ -757,6 +771,11 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
w_key.fullscreen();
}
}
Some("Delete" | "KP_Delete") => {
let uid = viewer_key.uid.borrow().clone();
w_key.close();
trash_photos(&ui_key, vec![uid]);
}
Some("Escape" | "q") => w_key.close(),
Some("w") if ctrl => w_key.close(),
_ => return glib::Propagation::Proceed,
+341 -5
View File
@@ -138,6 +138,16 @@ pub(crate) struct GalleryState {
/// The ListView itself, so a rebuild can scroll back to the row the user was
/// looking at.
pub(crate) list: gtk4::ListView,
/// True while the grid is picking photos rather than opening them. A tile
/// then toggles instead of activating, and shows a checkbox.
pub(crate) selecting: Cell<bool>,
/// The photos picked so far, by uid.
pub(crate) selected: RefCell<HashSet<String>>,
/// The Select toggle, the bar it reveals, and the bar's own widgets.
pub(crate) select_btn: gtk4::ToggleButton,
pub(crate) select_bar: gtk4::Revealer,
pub(crate) select_label: gtk4::Label,
pub(crate) select_trash: gtk4::Button,
}
/// How many photos to pull per [`Request::PhotosTimeline`] page.
@@ -250,7 +260,11 @@ impl GalleryRow {
al == bl
&& a.len() == b.len()
&& a.iter().zip(b).all(|(x, y)| {
x.photo.uid == y.photo.uid && x.width == y.width && x.height == y.height
x.photo.uid == y.photo.uid
&& x.width == y.width
&& x.height == y.height
&& x.selecting == y.selecting
&& x.selected == y.selected
})
}
_ => false,
@@ -281,6 +295,12 @@ pub(crate) struct GalleryWidgets {
pub(crate) empty_upload: gtk4::Button,
pub(crate) empty_import: gtk4::Button,
pub(crate) refresh: gtk4::Button,
/// The Select toggle and the bar it reveals.
pub(crate) select_btn: gtk4::ToggleButton,
pub(crate) select_bar: gtk4::Revealer,
pub(crate) select_label: gtk4::Label,
pub(crate) select_trash: gtk4::Button,
pub(crate) select_done: gtk4::Button,
/// The Albums grid, its own status page and the stack between them, plus the
/// Photos/Albums switcher and the back button out of an album.
pub(crate) albums: gtk4::FlowBox,
@@ -422,9 +442,50 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
import.add_css_class("circular");
let refresh = refresh_button();
// Picking photos is a mode, so its control is a toggle rather than a button.
let select_btn = gtk4::ToggleButton::builder()
.icon_name("selection-mode-symbolic")
.tooltip_text("Select photos")
.valign(gtk4::Align::Center)
.build();
select_btn.add_css_class("flat");
select_btn.add_css_class("circular");
// What the selection can do, revealed with the mode. A revealer rather than
// a hidden box so the grid slides down instead of jumping.
let select_label = gtk4::Label::builder()
.label("Select photos")
.hexpand(true)
.xalign(0.0)
.build();
let select_trash = gtk4::Button::builder()
.label("Move to Trash")
.valign(gtk4::Align::Center)
.sensitive(false)
.build();
select_trash.add_css_class("destructive-action");
let select_done = gtk4::Button::builder()
.icon_name("window-close-symbolic")
.tooltip_text("Leave selection (Esc)")
.valign(gtk4::Align::Center)
.build();
select_done.add_css_class("flat");
select_done.add_css_class("circular");
let select_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
select_box.add_css_class("toolbar");
select_box.add_css_class("bulk-bar");
select_box.append(&select_label);
select_box.append(&select_trash);
select_box.append(&select_done);
let select_bar = gtk4::Revealer::builder()
.transition_type(gtk4::RevealerTransitionType::SlideDown)
.child(&select_box)
.build();
let header_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
header_box.append(&back);
header_box.append(&titles);
header_box.append(&select_btn);
header_box.append(&refresh);
header_box.append(&import);
header_box.append(&upload);
@@ -502,6 +563,7 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
// The timeline (plus its pager) or the status page, never both.
let timeline = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
timeline.append(&select_bar);
timeline.append(&scroll);
timeline.append(&more);
@@ -566,6 +628,11 @@ pub(crate) fn build_gallery_page() -> (gtk4::Widget, GalleryWidgets) {
empty_upload,
empty_import,
refresh,
select_btn,
select_bar,
select_label,
select_trash,
select_done,
tabs,
favorites_btn,
dates,
@@ -700,7 +767,12 @@ pub(crate) fn wire_gallery_empty(ui: &Rc<Ui>, upload: &gtk4::Button, import: &gt
import.connect_clicked(move |_| ui_import.stack.set_visible_child_name("takeout"));
}
pub(crate) fn wire_gallery(ui: &Rc<Ui>, list: &gtk4::ListView, scroll: &gtk4::ScrolledWindow) {
pub(crate) fn wire_gallery(
ui: &Rc<Ui>,
list: &gtk4::ListView,
scroll: &gtk4::ScrolledWindow,
select_done: &gtk4::Button,
) {
let factory = gtk4::SignalListItemFactory::new();
factory.connect_setup(|_, item| {
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
@@ -820,10 +892,24 @@ pub(crate) fn wire_gallery(ui: &Rc<Ui>, list: &gtk4::ListView, scroll: &gtk4::Sc
});
scroll.add_controller(zoom_scroll);
// Ctrl+plus / Ctrl+minus / Ctrl+0, the keyboard equivalents.
// Ctrl+plus / Ctrl+minus / Ctrl+0, the keyboard equivalents, and the two
// keys selection mode owns.
let zoom_keys = gtk4::EventControllerKey::new();
let ui_keys = ui.clone();
zoom_keys.connect_key_pressed(move |_, key, _code, state| {
if ui_keys.gallery.selecting.get() {
match key.name().as_deref() {
Some("Escape") => {
set_selection_mode(&ui_keys, false);
return glib::Propagation::Stop;
}
Some("Delete" | "KP_Delete") => {
delete_selected(&ui_keys);
return glib::Propagation::Stop;
}
_ => {}
}
}
if !state.contains(gtk4::gdk::ModifierType::CONTROL_MASK) {
return glib::Propagation::Proceed;
}
@@ -842,6 +928,19 @@ pub(crate) fn wire_gallery(ui: &Rc<Ui>, list: &gtk4::ListView, scroll: &gtk4::Sc
load_gallery(&ui_more, true);
});
let ui_select = ui.clone();
ui.gallery.select_btn.clone().connect_toggled(move |btn| {
set_selection_mode(&ui_select, btn.is_active());
});
let ui_trash = ui.clone();
ui.gallery.select_trash.clone().connect_clicked(move |_| {
delete_selected(&ui_trash);
});
let ui_done = ui.clone();
select_done
.clone()
.connect_clicked(move |_| set_selection_mode(&ui_done, false));
// Filter toggles: flipping to a tab reloads the timeline filtered to that
// kind. Only the button being switched *on* acts — the group also fires a
// `toggled` for the one switching off, which this skips — and a redundant
@@ -994,6 +1093,11 @@ pub(crate) struct Tile {
pub(crate) photo: PhotoItem,
pub(crate) width: i32,
pub(crate) height: i32,
/// Whether the grid is in selection mode, and whether this photo is picked.
/// Carried on the tile rather than read at bind time so the row diff in
/// [`repaint_gallery`] sees a selection change and rebuilds that row.
pub(crate) selecting: bool,
pub(crate) selected: bool,
}
/// The aspect ratio to lay `photo` out at: what a decode has learned, else what
@@ -1027,6 +1131,8 @@ fn tile_ratio(ui: &Rc<Ui>, photo: &PhotoItem) -> f64 {
/// do either way.
pub(crate) fn justify_rows(ui: &Rc<Ui>, photos: &[PhotoItem], width: i32) -> Vec<Vec<Tile>> {
let ratios: Vec<f64> = photos.iter().map(|photo| tile_ratio(ui, photo)).collect();
let selecting = ui.gallery.selecting.get();
let selected = ui.gallery.selected.borrow();
let mut photos = photos.iter();
plan_rows(&ratios, width, ui.gallery.row_height.get())
.into_iter()
@@ -1035,6 +1141,8 @@ pub(crate) fn justify_rows(ui: &Rc<Ui>, photos: &[PhotoItem], width: i32) -> Vec
.into_iter()
.filter_map(|width| {
photos.next().map(|photo| Tile {
selecting,
selected: selecting && selected.contains(&photo.uid),
photo: photo.clone(),
width,
height,
@@ -1175,6 +1283,28 @@ pub(crate) fn photo_tile(ui: &Rc<Ui>, tile: Tile) -> gtk4::Button {
overlay.add_overlay(&badge);
}
// Selection mode marks every tile, picked or not: a check that only appears
// once a photo is chosen leaves the user guessing what else is clickable.
if tile.selecting {
let check = gtk4::Image::builder()
.icon_name(if tile.selected {
"checkbox-checked-symbolic"
} else {
"checkbox-symbolic"
})
.pixel_size(16)
.halign(gtk4::Align::Start)
.valign(gtk4::Align::Start)
.margin_start(6)
.margin_top(6)
.build();
check.add_css_class("photo-check");
if tile.selected {
check.add_css_class("photo-check-on");
}
overlay.add_overlay(&check);
}
let button = gtk4::Button::builder()
.child(&overlay)
.width_request(tile.width)
@@ -1183,17 +1313,44 @@ pub(crate) fn photo_tile(ui: &Rc<Ui>, tile: Tile) -> gtk4::Button {
.build();
button.add_css_class("photo-tile");
button.add_css_class("flat");
if tile.selected {
button.add_css_class("photo-tile-selected");
}
// Clip the thumbnail to the tile's rounded corners.
button.set_overflow(gtk4::Overflow::Hidden);
want_thumb(ui, &tile.photo, &picture);
// Ctrl or Shift on a plain click starts a selection — the gesture people
// already use for picking things, without having to find the Select button
// first. Claimed in the capture phase so the click never also opens the
// photo it was picking.
let modifier = gtk4::GestureClick::new();
modifier.set_propagation_phase(gtk4::PropagationPhase::Capture);
let ui_modifier = ui.clone();
let modifier_uid = tile.photo.uid.clone();
modifier.connect_pressed(move |gesture, _, _, _| {
let state = gesture.current_event_state().intersects(
gtk4::gdk::ModifierType::CONTROL_MASK | gtk4::gdk::ModifierType::SHIFT_MASK,
);
if !state || ui_modifier.gallery.selecting.get() {
return;
}
gesture.set_state(gtk4::EventSequenceState::Claimed);
set_selection_mode(&ui_modifier, true);
toggle_selected(&ui_modifier, &modifier_uid);
});
button.add_controller(modifier);
// A still opens in the in-app lightbox; a video can't render there, so it
// downloads and hands off to an external player instead.
// downloads and hands off to an external player instead. In selection mode
// a tile picks instead of opening: the lightbox is one Escape away.
let ui_open = ui.clone();
let uid = tile.photo.uid.clone();
button.connect_clicked(move |_| {
if is_video {
if ui_open.gallery.selecting.get() {
toggle_selected(&ui_open, &uid);
} else if is_video {
play_video(&ui_open, uid.clone());
} else {
open_photo_viewer(&ui_open, uid.clone());
@@ -1202,6 +1359,185 @@ pub(crate) fn photo_tile(ui: &Rc<Ui>, tile: Tile) -> gtk4::Button {
button
}
/// Enter or leave selection mode, repainting the tiles so their checkboxes
/// appear or go. Leaving drops the selection: a hidden selection that a later
/// Delete would act on is a trap.
pub(crate) fn set_selection_mode(ui: &Rc<Ui>, selecting: bool) {
if ui.gallery.selecting.get() == selecting {
return;
}
ui.gallery.selecting.set(selecting);
if !selecting {
ui.gallery.selected.borrow_mut().clear();
}
if ui.gallery.select_btn.is_active() != selecting {
ui.gallery.select_btn.set_active(selecting);
}
sync_selection_bar(ui);
repaint_gallery(ui);
}
/// Pick or unpick one photo.
fn toggle_selected(ui: &Rc<Ui>, uid: &str) {
{
let mut selected = ui.gallery.selected.borrow_mut();
if !selected.remove(uid) {
selected.insert(uid.to_string());
}
}
sync_selection_bar(ui);
repaint_gallery(ui);
}
/// Reflect the selection in the bar above the grid.
fn sync_selection_bar(ui: &Rc<Ui>) {
let count = ui.gallery.selected.borrow().len();
ui.gallery.select_label.set_label(&match count {
0 => "Select photos".to_string(),
1 => "1 selected".to_string(),
n => format!("{n} selected"),
});
ui.gallery.select_trash.set_sensitive(count > 0);
ui.gallery
.select_bar
.set_reveal_child(ui.gallery.selecting.get());
}
/// Confirm, then move every selected photo to Proton trash.
pub(crate) fn delete_selected(ui: &Rc<Ui>) {
let uids: Vec<String> = ui.gallery.selected.borrow().iter().cloned().collect();
if uids.is_empty() {
return;
}
let dialog = adw::AlertDialog::builder()
.heading("Move to Trash")
.body(match uids.len() {
1 => "Move this photo to Trash?".to_string(),
n => format!("Move {n} photos to Trash?"),
})
.build();
dialog.add_response("cancel", "Cancel");
dialog.add_response("trash", "Move to Trash");
dialog.set_response_appearance("trash", adw::ResponseAppearance::Destructive);
dialog.set_default_response(Some("cancel"));
dialog.set_close_response("cancel");
let win = ui_window(ui);
let ui = ui.clone();
dialog.connect_response(None, move |_, response| {
if response == "trash" {
trash_photos(&ui, uids.clone());
}
});
dialog.present(win.as_ref());
}
/// Move photos to Proton trash, taking them out of the grid straight away.
///
/// The removal is optimistic because the alternative — a grid that keeps showing
/// photos the user just deleted until a refresh lands — reads as a failure. What
/// the server refuses comes back, so the grid still ends up telling the truth,
/// and what succeeded is one Undo away for as long as the toast is up.
pub(crate) fn trash_photos(ui: &Rc<Ui>, uids: Vec<String>) {
let removed = remove_photos(ui, &uids);
set_selection_mode(ui, false);
ui.busy_begin();
let rx = spawn_request(
ui.dirs.control_socket(),
Request::TrashNodes { uids: uids.clone() },
);
let ui = ui.clone();
glib::spawn_future_local(async move {
let reply = rx.recv().await;
ui.busy_end();
match reply {
Ok(Ok(Response::Trashed { trashed, failed })) => {
// Put back whatever stayed on the server, in its timeline
// position rather than at the end.
if !failed.is_empty() {
let kept: Vec<PhotoItem> = removed
.iter()
.filter(|photo| failed.iter().any(|f| f.uid == photo.uid))
.cloned()
.collect();
restore_photos(&ui, kept);
let message = failed
.first()
.map(|f| f.message.clone())
.unwrap_or_else(|| "The server refused.".to_string());
toast_error(&ui, "Some photos couldn't be moved to Trash", &message);
}
if trashed.is_empty() {
return;
}
let count = trashed.len();
let message = match count {
1 => "Moved 1 photo to Trash".to_string(),
n => format!("Moved {n} photos to Trash"),
};
toast_action(&ui, &message, "Undo", move |ui| {
restore_uids(ui, trashed.clone(), count);
load_gallery(ui, false);
});
}
Ok(Ok(Response::Error { message, kind })) => {
restore_photos(&ui, removed.clone());
toast_failure(&ui, "Couldn't move to Trash", &message, kind);
}
_ => {
restore_photos(&ui, removed.clone());
toast_error(
&ui,
"Couldn't move to Trash",
"The mount service didn't respond.",
);
}
}
});
}
/// Take photos out of the loaded model, returning them so a failure can put them
/// back.
fn remove_photos(ui: &Rc<Ui>, uids: &[String]) -> Vec<PhotoItem> {
let model = &ui.gallery.model;
let mut removed = Vec::new();
let mut index = 0;
while index < model.n_items() {
let item = model
.item(index)
.and_downcast::<BoxedAnyObject>()
.map(|obj| obj.borrow::<PhotoItem>().clone());
match item {
Some(photo) if uids.contains(&photo.uid) => {
model.remove(index);
removed.push(photo);
}
_ => index += 1,
}
}
repaint_gallery(ui);
removed
}
/// Put photos back into the loaded model, at their place in the timeline.
fn restore_photos(ui: &Rc<Ui>, photos: Vec<PhotoItem>) {
let model = &ui.gallery.model;
for photo in photos {
// The timeline is newest first, so a photo belongs before the first
// entry older than it.
let at = (0..model.n_items())
.find(|index| {
model
.item(*index)
.and_downcast::<BoxedAnyObject>()
.is_some_and(|obj| obj.borrow::<PhotoItem>().capture_time < photo.capture_time)
})
.unwrap_or(model.n_items());
model.insert(at, &BoxedAnyObject::new(photo));
}
repaint_gallery(ui);
}
/// Give `picture` its thumbnail: straight from the texture cache when it's there,
/// otherwise register the tile as waiting and get the thumbnail moving — decoding
/// it if the daemon already had it cached on disk, or asking the daemon for it.
+25
View File
@@ -14,6 +14,23 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
Schema: **29** (`trash.parent_uid`); SDK bumped to `proton-sdk` / `proton-drive-rs` **0.6.5**.
### Changed
- **The gallery lays photos out in justified rows.** Every tile is now its own photo's shape:
photos are taken in capture order until they no longer fit the target height, and the row is
then scaled to land exactly on the right margin. Nothing is cropped and nothing is
letterboxed, which is what the square centre-cropped tiles used to do to portraits and
panoramas. The last row of a day stays at the target height rather than being stretched, and
Ctrl+scroll / Ctrl+± now changes that target height. Gaps and tile corners are smaller, so the
page reads as photos rather than as cards.
- **Thumbnails stay on screen while you scroll.** A decoded thumbnail now lands in the texture
cache even when the tile that asked for it has already been recycled, the cache is a real LRU
holding 1500 textures instead of 600, and the rows just past the edge of the window are
fetched ahead of the scroll direction. Scrolling fast through a large timeline no longer
leaves a screen of blank tiles behind.
- **Refresh on the Photos page really refreshes.** It now waits for the timeline to come back
from the server before it answers, instead of only clearing the freshness stamp and letting
the next read serve the stale page. The timeline is also considered stale after 60 s rather
than 5 minutes.
- **Restore puts back the whole tree.** The trash is a flat list, but you delete a shape: a
folder, and often things you had already deleted inside it. Restoring a folder now also
restores the items that were trashed inside it, and restoring one of those items first
@@ -23,6 +40,14 @@ Schema: **29** (`trash.parent_uid`); SDK bumped to `proton-sdk` / `proton-drive-
not cancel the rest.
### Added
- **Delete from the gallery.** Select photos with the Select button, with Ctrl+click or with
Shift+click, then Move to Trash — or press Delete in the lightbox. The tiles leave the grid
immediately with an Undo toast, and the photos land in Proton's trash, where they stay
recoverable. A photo the server refuses to trash comes back into the grid.
- **A photo deleted on your phone disappears here.** The daemon now follows the photos volume's
own event stream, so a photo trashed on another device leaves the gallery within about ten
seconds instead of waiting for the timeline to go stale.
- **`pdfs diagnostics`.** Reports what the daemon is doing right now: every worker thread with the job
it holds and for how long, queue depths per lane, the control requests still in flight, resident
size and the pending-op count. Answers even while the daemon is wedged, which is the only time
+10
View File
@@ -142,6 +142,16 @@ The gallery can mark and filter favourites, through the SDK's photo-tag API (`up
Favouriting a photo that is not on this account's own photos volume (shared with us, or album-only) needs it re-encrypted for our timeline root, which the SDK does not implement; the daemon surfaces that as an error rather than silently doing nothing.
### Justified Gallery, Gallery Delete (Implemented)
**Files**: [`photos.rs`](../crates/pdfs-gui/src/app/pages/photos.rs), [`photo_viewer.rs`](../crates/pdfs-gui/src/app/pages/photo_viewer.rs), [`photos.rs`](../crates/pdfs-fuse/src/photos.rs), [`background.rs`](../crates/pdfs-fuse/src/background.rs)
The gallery lays each day out in justified rows (`justify_rows` / `plan_rows` / `fit_row`): photos are taken in capture order until their summed aspect ratio no longer fits the target row height, and the row is then scaled so it ends exactly on the content width. A tile is therefore its own photo's shape, and `ContentFit::Contain` has nothing left to crop. The last row of a day is left at the target height rather than stretched, because a day holding two photos is a short day, not a layout fault.
- **Ratios that are not known yet.** `PhotoItem::ratio` is persisted, but a photo that has never been decoded has none. Those are laid out square and remembered in `assumed_ratios`; when a decode proves the real shape the day re-flows on the existing debounce timer, so a screenful of decodes costs one re-flow rather than one per photo. Ratios are clamped to 0.4–3.0 so a single panorama cannot decide what a row looks like.
- **Thumbnails survive recycling.** A reply for a tile that has already been re-bound still lands in the texture cache, which is an LRU of 1500 textures keyed by uid, and the rows just outside the realised range are prefetched in the scroll direction.
- **Delete is uid-addressed.** The photos volume is not in the FUSE mount, so the path-based `Request::Delete` cannot reach it. `Request::TrashNodes { uids }` trashes through the SDK and answers `Response::Trashed { trashed, failed }`; the daemon then drops those rows with `Db::photos_delete` (album membership included) so the gallery does not wait for a timeline refresh. The GUI removes the tiles optimistically with an Undo toast and puts back anything the server refused.
- **Remote deletions arrive on their own.** The Drive volume and the photos volume have separate event streams, so `run_photos_event_sync` polls the photos volume with its own cursor (`photos_event_cursor` in the state table). Trash and delete events remove rows; anything else only invalidates freshness, because there is no inode space to converge on this volume. Together with a 60 s `TIMELINE_TTL` and a `RefreshScope::Photos` that now awaits the refresh, a photo deleted on a phone leaves the grid in about ten seconds.
### Persistent SDK Entity Cache (Implemented)
**Files**: [`sdkcache.rs`](../crates/pdfs-core/src/sdkcache.rs), [`auth.rs`](../crates/pdfs-core/src/auth.rs), [`background.rs`](../crates/pdfs-fuse/src/background.rs)