feat(gui): show a grouped shot as one tile, and reach its other files

The grouping landed in the daemon; this is what it looks like. A tile whose shot
was stored as more than one file badges that in the corner — "RAW" when one of
the members is a raw file, since that is the word the person is looking for, and
a plain count otherwise (a live photo, a burst). The lightbox gains a control
that steps through the group's files, which is the only way to reach the RAW now
that the grid shows the shot once.

Deleting says what it will do: the daemon takes the whole group, so the
confirmation names the file count when a selection stands for more files than
tiles.
This commit is contained in:
nvrl committed 2026-09-19 15:39:14 +02:00
1 parent afd71713d5
commit c1df3b6cc2
8 files changed
+283 -5

No files matched your search

+35
View File
@@ -68,6 +68,11 @@ pub enum Request {
#[serde(default)] #[serde(default)]
favorites: bool, favorites: bool,
}, },
/// Every photo of one photo's group — the JPEG and the RAW of one shot, a
/// live photo and its clip. Replies with [`Response::Photos`], holding one
/// item per file in server order; a photo that stands alone comes back as a
/// single item. The lightbox uses it to switch between a shot's files.
PhotoGroup { uid: String },
/// The months the timeline spans (newest first, with per-month counts) so a /// The months the timeline spans (newest first, with per-month counts) so a
/// front-end can build a date scrubber without paging the whole library. /// front-end can build a date scrubber without paging the whole library.
/// `kind` scopes the counts to one tab when set. /// `kind` scopes the counts to one tab when set.
@@ -99,6 +104,10 @@ pub enum Request {
/// part of the FUSE mount: the gallery knows a photo only by its uid, and /// 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 /// [`Request::Delete`] has no path to take for it. The nodes land in Proton
/// trash, so this stays recoverable from the Trash page. /// trash, so this stays recoverable from the Trash page.
///
/// A photo that belongs to a group takes the whole group with it: trashing
/// the JPEG of a shot and leaving its RAW behind is not what anyone means by
/// deleting that photo.
TrashNodes { uids: Vec<String> }, TrashNodes { uids: Vec<String> },
/// Fetch thumbnails for ordinary Drive image files shown outside the Photos /// Fetch thumbnails for ordinary Drive image files shown outside the Photos
/// timeline. The modification time is the cache validity tag, so replacing /// timeline. The modification time is the cache validity tag, so replacing
@@ -1956,6 +1965,32 @@ mod tests {
assert!(items[0].name.is_empty()); assert!(items[0].name.is_empty());
} }
/// The lightbox asks for one shot's files by the uid of the tile that was
/// clicked, and gets them back in the shape the gallery already paints.
#[test]
fn photo_group_requests_roundtrip() {
let request = Request::PhotoGroup {
uid: "volume~first".into(),
};
let line = serde_json::to_string(&request).unwrap();
assert_eq!(line, r#"{"PhotoGroup":{"uid":"volume~first"}}"#);
let decoded: Request = serde_json::from_str(&line).unwrap();
assert_eq!(line, serde_json::to_string(&decoded).unwrap());
}
/// The group fields are new, so a page from a daemon that predates them has
/// to keep painting: one file, nothing to badge.
#[test]
fn an_older_daemons_photo_has_no_group() {
let item: PhotoItem = serde_json::from_str(
r#"{"uid":"volume~first","capture_time":1700000000,"thumb_path":null,
"name":"a.jpg","ratio":1.5,"no_thumb":false}"#,
)
.unwrap();
assert_eq!(item.group_size, 0, "absent reads the same as one file");
assert!(!item.has_raw);
}
/// The gallery trashes by uid, and reports per-uid outcomes back: a partial /// 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. /// failure has to survive the wire so the GUI can put those tiles back.
#[test] #[test]
+8
View File
@@ -293,6 +293,14 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
}, },
Err(e) => CtlResponse::error(e), Err(e) => CtlResponse::error(e),
}, },
Ok(CtlRequest::PhotoGroup { uid }) => match core.photo_group(&uid) {
Ok(items) => CtlResponse::Photos {
available: true,
items,
counts: None,
},
Err(e) => CtlResponse::error(e),
},
Ok(CtlRequest::SetPhotoFavorite { uid, favorite }) => match parse_uid(&uid) { Ok(CtlRequest::SetPhotoFavorite { uid, favorite }) => match parse_uid(&uid) {
Some(node_uid) => match core.set_photo_favorite(&node_uid, favorite) { Some(node_uid) => match core.set_photo_favorite(&node_uid, favorite) {
Ok(()) => CtlResponse::Ok { Ok(()) => CtlResponse::Ok {
+41
View File
@@ -505,6 +505,41 @@ impl Core {
Ok(()) Ok(())
} }
/// Every file of one photo's group, in server order, as wire items. A photo
/// the timeline does not hold comes back empty, which the lightbox reads as
/// "nothing to switch between".
pub(crate) fn photo_group(&self, uid: &str) -> CoreResult<Vec<PhotoItem>> {
let members = self.db.photos_group(uid).map_err(CoreError::from)?;
Ok(members
.into_iter()
.map(|photo| self.photo_item(photo))
.collect())
}
/// Every uid asked for, plus the other files of any group they belong to,
/// each once. A uid the timeline does not hold (an album photo on someone
/// else's volume) is passed through unchanged.
fn expand_photo_groups(&self, uids: &[NodeUid]) -> Vec<NodeUid> {
let mut out: Vec<NodeUid> = Vec::with_capacity(uids.len());
let mut seen: HashSet<String> = HashSet::new();
for uid in uids {
let members = self.db.photos_group(&uid.to_string()).unwrap_or_default();
let mut group: Vec<NodeUid> = members
.iter()
.filter_map(|photo| parse_uid(&photo.uid))
.collect();
if group.is_empty() {
group.push(uid.clone());
}
for member in group {
if seen.insert(member.to_string()) {
out.push(member);
}
}
}
out
}
/// Trash photos by uid, and forget them locally. /// Trash photos by uid, and forget them locally.
/// ///
/// The gallery is not part of the FUSE mount, so this is the photos-side /// The gallery is not part of the FUSE mount, so this is the photos-side
@@ -515,10 +550,16 @@ impl Core {
/// The server answers per node, and so does this: a batch where one photo /// 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 /// fails still removes the ones that went, and the caller is told which is
/// which. Only what actually left the server is forgotten locally. /// which. Only what actually left the server is forgotten locally.
///
/// A photo that is one file of a group takes its whole group with it. The
/// gallery shows a RAW+JPEG shot as one tile, so deleting that tile has to
/// delete the shot — leaving the RAW behind would put the photo back on the
/// Raw tab and nowhere else.
pub(crate) fn trash_photos(&self, uids: &[NodeUid]) -> CoreResult<TrashOutcome> { pub(crate) fn trash_photos(&self, uids: &[NodeUid]) -> CoreResult<TrashOutcome> {
if uids.is_empty() { if uids.is_empty() {
return Ok((Vec::new(), Vec::new())); return Ok((Vec::new(), Vec::new()));
} }
let uids = &self.expand_photo_groups(uids);
for uid in uids { for uid in uids {
self.require_uid_writable(uid) self.require_uid_writable(uid)
.map_err(|errno| self.errno_error(errno, "trash access"))?; .map_err(|errno| self.errno_error(errno, "trash access"))?;
+54
View File
@@ -265,6 +265,7 @@ fn load_proton_theme() {
.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-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\ .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\
.photo-tile:hover .photo-video-badge {{ background: alpha({PROTON_PURPLE}, 0.85); }}\n\ .photo-tile:hover .photo-video-badge {{ background: alpha({PROTON_PURPLE}, 0.85); }}\n\
.photo-group-badge {{ font-size: 0.68rem; font-weight: 700; letter-spacing: 0.04em; color: white; background: rgba(0, 0, 0, 0.55); border-radius: 6px; padding: 1px 5px; margin: 6px; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9); }}\n\
.photo-tile:hover .photo-caption {{ opacity: 1; background: linear-gradient(to top, rgba(0, 0, 0, 0.55), rgba(0, 0, 0, 0)); }}\n\ .photo-tile:hover .photo-caption {{ opacity: 1; background: linear-gradient(to top, rgba(0, 0, 0, 0.55), rgba(0, 0, 0, 0)); }}\n\
.album-card {{ padding: 0; border-radius: 16px; transition: background 180ms ease; }}\n\ .album-card {{ padding: 0; border-radius: 16px; transition: background 180ms ease; }}\n\
.album-card:hover {{ background: alpha(currentColor, 0.07); }}\n\ .album-card:hover {{ background: alpha(currentColor, 0.07); }}\n\
@@ -1287,6 +1288,59 @@ mod tests {
assert!(plan_rows(&[], 1000, ROW_DEFAULT).is_empty()); assert!(plan_rows(&[], 1000, ROW_DEFAULT).is_empty());
} }
/// One file of a shot, for the group-switch tests.
fn member(uid: &str, name: Option<&str>, kind: PhotoKind) -> PhotoItem {
PhotoItem {
uid: uid.into(),
capture_time: 0,
thumb_path: None,
name: name.map(str::to_string),
ratio: None,
no_thumb: false,
kind,
favorite: false,
group_size: 2,
has_raw: kind == PhotoKind::Raw,
}
}
/// The lightbox steps through a shot's files and comes back round, so a
/// burst of three is as reachable as a RAW+JPEG pair.
#[test]
fn the_group_switch_cycles_through_every_file() {
let members = [
member("jpeg", Some("IMG_1.JPG"), PhotoKind::Photo),
member("raw", Some("IMG_1.CR2"), PhotoKind::Raw),
member("clip", None, PhotoKind::Video),
];
let next = |uid| next_group_member(&members, uid).map(|item| item.uid.as_str());
assert_eq!(next("jpeg"), Some("raw"));
assert_eq!(next("raw"), Some("clip"));
assert_eq!(next("clip"), Some("jpeg"));
// A photo that is not in the group leaves the button alone rather than
// jumping somewhere arbitrary.
assert_eq!(next("elsewhere"), None);
assert!(next_group_member(&[], "jpeg").is_none());
}
/// The switch names what it will show; a file whose name the daemon has not
/// resolved still gets a sentence rather than a blank.
#[test]
fn a_group_member_without_a_name_is_still_described() {
assert_eq!(
member_label(&member("raw", Some("IMG_1.CR2"), PhotoKind::Raw)),
"IMG_1.CR2"
);
assert_eq!(
member_label(&member("raw", None, PhotoKind::Raw)),
"the raw file"
);
assert_eq!(
member_label(&member("clip", Some(""), PhotoKind::Video)),
"the video"
);
}
#[test] #[test]
fn a_window_narrower_than_one_photo_still_lays_it_out() { fn a_window_narrower_than_one_photo_still_lays_it_out() {
// A tile of at least one px, rather than a zero-width widget or a // A tile of at least one px, rather than a zero-width widget or a
@@ -24,6 +24,11 @@ pub(crate) struct Viewer {
/// uses for its pin switch, so painting a photo doesn't fire a round-trip. /// uses for its pin switch, so painting a photo doesn't fire a round-trip.
pub(crate) favorite: gtk4::ToggleButton, pub(crate) favorite: gtk4::ToggleButton,
pub(crate) favorite_suppress: Cell<bool>, pub(crate) favorite_suppress: Cell<bool>,
/// The button that switches between the files of one shot, and the files it
/// switches between (the JPEG and the RAW of a photo, a live photo and its
/// clip). Empty, and the button hidden, for a photo stored as one file.
pub(crate) group_btn: gtk4::Button,
pub(crate) group: RefCell<Vec<PhotoItem>>,
/// uid of the photo currently on screen. /// uid of the photo currently on screen.
pub(crate) uid: RefCell<String>, pub(crate) uid: RefCell<String>,
/// On-disk path of the full-size photo, once it has been downloaded. /// On-disk path of the full-size photo, once it has been downloaded.
@@ -81,6 +86,59 @@ fn set_gallery_favorite(ui: &Rc<Ui>, uid: &str, favorite: bool) {
/// Show the photo behind `uid`: paint its (already cached) thumbnail immediately /// Show the photo behind `uid`: paint its (already cached) thumbnail immediately
/// so the lightbox never opens on a blank screen, ask the daemon for the /// so the lightbox never opens on a blank screen, ask the daemon for the
/// full-size file, and swap it in — plus its EXIF — when it lands. /// full-size file, and swap it in — plus its EXIF — when it lands.
/// Ask the daemon which files this shot was stored as, and offer a switch when
/// there is more than one. The grid shows such a shot as a single tile, so the
/// lightbox is the only place the RAW is reachable.
fn load_photo_group(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: &str) {
viewer.group.borrow_mut().clear();
viewer.group_btn.set_visible(false);
let rx = spawn_request(
ui.dirs.control_socket(),
Request::PhotoGroup {
uid: uid.to_string(),
},
);
let viewer = viewer.clone();
let uid = uid.to_string();
glib::spawn_future_local(async move {
let Ok(Ok(Response::Photos { items, .. })) = rx.recv().await else {
return;
};
// A late reply for a photo the user has already left must not relabel
// the button under the one now on screen.
if *viewer.uid.borrow() != uid || items.len() < 2 {
return;
}
if let Some(next) = next_group_member(&items, &uid) {
viewer
.group_btn
.set_tooltip_text(Some(&format!("Show {}", member_label(next))));
}
*viewer.group.borrow_mut() = items;
viewer.group_btn.set_visible(true);
});
}
/// The member after `uid`, wrapping round — the button steps through a shot's
/// files rather than toggling two of them, so a burst of three works too.
pub(crate) fn next_group_member<'a>(members: &'a [PhotoItem], uid: &str) -> Option<&'a PhotoItem> {
let at = members.iter().position(|item| item.uid == uid)?;
members.get((at + 1) % members.len())
}
/// What to call one file of a shot: its name, or its kind when the daemon has
/// not resolved a name for it.
pub(crate) fn member_label(item: &PhotoItem) -> String {
match item.name.as_deref() {
Some(name) if !name.is_empty() => name.to_string(),
_ => match item.kind {
PhotoKind::Raw => "the raw file".to_string(),
PhotoKind::Video => "the video".to_string(),
PhotoKind::Photo => "the photo".to_string(),
},
}
}
pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) { pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
viewer.spinner.set_visible(true); viewer.spinner.set_visible(true);
viewer.spinner.start(); viewer.spinner.start();
@@ -96,6 +154,7 @@ pub(crate) fn load_photo(ui: &Rc<Ui>, viewer: &Rc<Viewer>, uid: String) {
.and_downcast::<BoxedAnyObject>() .and_downcast::<BoxedAnyObject>()
.is_some_and(|boxed| boxed.borrow::<PhotoItem>().favorite); .is_some_and(|boxed| boxed.borrow::<PhotoItem>().favorite);
show_favorite(viewer, favorite); show_favorite(viewer, favorite);
load_photo_group(ui, viewer, &uid);
// The thumbnail the gallery already decoded stands in for the full photo // The thumbnail the gallery already decoded stands in for the full photo
// while it downloads: blurry for a moment beats black for a second. // while it downloads: blurry for a moment beats black for a second.
@@ -503,6 +562,11 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
favorite_btn.add_css_class("flat"); favorite_btn.add_css_class("flat");
favorite_btn.add_css_class("viewer-action-btn"); favorite_btn.add_css_class("viewer-action-btn");
// Only shown for a shot stored as more than one file; `load_photo_group`
// decides that per photo.
let group_btn = action("view-paged-symbolic", "Other files of this photo");
group_btn.set_visible(false);
let download_btn = action("document-save-symbolic", "Save a copy…"); let download_btn = action("document-save-symbolic", "Save a copy…");
let delete_btn = action("user-trash-symbolic", "Move to Trash (Delete)"); let delete_btn = action("user-trash-symbolic", "Move to Trash (Delete)");
let open_ext_btn = action("document-open-symbolic", "Open with another app"); let open_ext_btn = action("document-open-symbolic", "Open with another app");
@@ -514,6 +578,7 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
top_bar.set_valign(gtk4::Align::Start); top_bar.set_valign(gtk4::Align::Start);
top_bar.set_hexpand(true); top_bar.set_hexpand(true);
top_bar.append(&titles); top_bar.append(&titles);
top_bar.append(&group_btn);
top_bar.append(&favorite_btn); top_bar.append(&favorite_btn);
top_bar.append(&info_toggle); top_bar.append(&info_toggle);
top_bar.append(&download_btn); top_bar.append(&download_btn);
@@ -616,6 +681,8 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
info_map: info_map.clone(), info_map: info_map.clone(),
favorite: favorite_btn.clone(), favorite: favorite_btn.clone(),
favorite_suppress: Cell::new(false), favorite_suppress: Cell::new(false),
group_btn: group_btn.clone(),
group: RefCell::new(Vec::new()),
coords: RefCell::new(None), coords: RefCell::new(None),
uid: RefCell::new(initial_uid.clone()), uid: RefCell::new(initial_uid.clone()),
path: RefCell::new(None), path: RefCell::new(None),
@@ -709,6 +776,18 @@ pub(crate) fn open_photo_viewer(ui: &Rc<Ui>, initial_uid: String) {
trash_photos(&ui_delete, vec![uid]); trash_photos(&ui_delete, vec![uid]);
}); });
let ui_group = ui.clone();
let viewer_group = viewer.clone();
group_btn.connect_clicked(move |_| {
let uid = viewer_group.uid.borrow().clone();
let next =
next_group_member(&viewer_group.group.borrow(), &uid).map(|item| item.uid.clone());
if let Some(next) = next {
*viewer_group.uid.borrow_mut() = next.clone();
load_photo(&ui_group, &viewer_group, next);
}
});
let w_download = window.clone(); let w_download = window.clone();
let viewer_download = viewer.clone(); let viewer_download = viewer.clone();
download_btn.connect_clicked(move |_| { download_btn.connect_clicked(move |_| {
+46 -4
View File
@@ -1283,6 +1283,24 @@ pub(crate) fn photo_tile(ui: &Rc<Ui>, tile: Tile) -> gtk4::Button {
overlay.add_overlay(&badge); overlay.add_overlay(&badge);
} }
// A shot stored as more than one file says so, in the corner the caption
// does not use. "RAW" is the useful word when one of the members is a raw
// file — that is what the person wants to find — and a plain count covers
// the rest (a live photo, a burst).
if tile.photo.has_raw || tile.photo.group_size > 1 {
let badge = gtk4::Label::builder()
.label(if tile.photo.has_raw {
"RAW".to_string()
} else {
format!("{}", tile.photo.group_size)
})
.halign(gtk4::Align::End)
.valign(gtk4::Align::Start)
.build();
badge.add_css_class("photo-group-badge");
overlay.add_overlay(&badge);
}
// Selection mode marks every tile, picked or not: a check that only appears // 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. // once a photo is chosen leaves the user guessing what else is clickable.
if tile.selecting { if tile.selecting {
@@ -1404,17 +1422,41 @@ fn sync_selection_bar(ui: &Rc<Ui>) {
} }
/// Confirm, then move every selected photo to Proton trash. /// Confirm, then move every selected photo to Proton trash.
/// How many files the selected tiles stand for. A tile whose photo the model no
/// longer holds counts as one, which is what it looks like on screen.
fn selected_file_count(ui: &Rc<Ui>, uids: &[String]) -> usize {
let wanted: HashSet<&str> = uids.iter().map(String::as_str).collect();
let mut files = 0;
for idx in 0..ui.gallery.model.n_items() {
let Some(boxed) = ui.gallery.model.item(idx).and_downcast::<BoxedAnyObject>() else {
continue;
};
let photo = boxed.borrow::<PhotoItem>();
if wanted.contains(photo.uid.as_str()) {
files += photo.group_size.max(1) as usize;
}
}
files.max(uids.len())
}
pub(crate) fn delete_selected(ui: &Rc<Ui>) { pub(crate) fn delete_selected(ui: &Rc<Ui>) {
let uids: Vec<String> = ui.gallery.selected.borrow().iter().cloned().collect(); let uids: Vec<String> = ui.gallery.selected.borrow().iter().cloned().collect();
if uids.is_empty() { if uids.is_empty() {
return; return;
} }
// A selected tile can stand for more than one file — a shot kept as RAW and
// JPEG — and all of them go. The dialog says so rather than letting the user
// find out from the trash.
let files = selected_file_count(ui, &uids);
let body = match (uids.len(), files) {
(1, 1) => "Move this photo to Trash?".to_string(),
(1, files) => format!("Move this photo to Trash? It is stored as {files} files."),
(n, files) if files == n => format!("Move {n} photos to Trash?"),
(n, files) => format!("Move {n} photos to Trash? They are stored as {files} files."),
};
let dialog = adw::AlertDialog::builder() let dialog = adw::AlertDialog::builder()
.heading("Move to Trash") .heading("Move to Trash")
.body(match uids.len() { .body(body)
1 => "Move this photo to Trash?".to_string(),
n => format!("Move {n} photos to Trash?"),
})
.build(); .build();
dialog.add_response("cancel", "Cancel"); dialog.add_response("cancel", "Cancel");
dialog.add_response("trash", "Move to Trash"); dialog.add_response("trash", "Move to Trash");
+9 -1
View File
@@ -11,7 +11,8 @@ scratch (user data in `staging/` and `recovery/` is never touched by this).
## [Unreleased] ## [Unreleased]
Schema: **29** (`trash.parent_uid`); SDK bumped to `proton-sdk` / `proton-drive-rs` **0.6.5**. Schema: **30** (`photos.content_hash` / `main_uid` / `group_key`); SDK bumped to `proton-sdk` /
`proton-drive-rs` **0.6.5**.
### Changed ### Changed
- **The gallery lays photos out in justified rows.** Every tile is now its own photo's shape: - **The gallery lays photos out in justified rows.** Every tile is now its own photo's shape:
@@ -40,6 +41,13 @@ Schema: **29** (`trash.parent_uid`); SDK bumped to `proton-sdk` / `proton-drive-
not cancel the rest. not cancel the rest.
### Added ### Added
- **A RAW and its JPEG are one photo.** A camera that writes both put two tiles of the same shot
in the timeline; now it puts one, badged `RAW`, and the lightbox has a control that switches
between the files. The Raw tab still lists every raw file individually, because that is what
the tab is for. Grouping follows the server's own photo relation where it has one — which
also collapses live photos and bursts — and otherwise pairs files with the same name taken on
the same day where one is raw and the other is not. Deleting the tile deletes the whole shot,
and the confirmation says how many files that is.
- **Delete from the gallery.** Select photos with the Select button, with Ctrl+click or with - **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 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 immediately with an Undo toast, and the photos land in Proton's trash, where they stay
+11
View File
@@ -152,6 +152,17 @@ The gallery lays each day out in justified rows (`justify_rows` / `plan_rows` /
- **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. - **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. - **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.
### RAW + JPEG Grouping (Implemented)
**Files**: [`photos.rs`](../crates/pdfs-core/src/db/photos.rs), [`migrations.rs`](../crates/pdfs-core/src/db/migrations.rs), [`photos.rs`](../crates/pdfs-fuse/src/photos.rs), [`photo_viewer.rs`](../crates/pdfs-gui/src/app/pages/photo_viewer.rs)
Schema **v30** adds `content_hash`, `main_uid` and an indexed `group_key` to `photos`. The first two come from the server's `PhotoProperties` and are filled by the enrichment pass in `refresh_timeline` that already resolves each photo's name, media type and favourite tag; `group_key` is computed in `photos_replace`, where the whole timeline is in hand.
- **Precedence.** The server relation first (`main_photo_uid` and `related_photo_uids`, read from whichever end resolves — the two can land in different enrichment chunks); then same capture day, same case-insensitive name stem, and one member raw while the other is not; otherwise the photo is its own group. The union-find pass is over the timeline in memory, so it costs one pass per refresh rather than a query per photo.
- **Representative.** The non-raw member when there is one — a JPEG decodes in milliseconds and is what the person expects to see. `group_key` holds that member's uid, so "is this the tile" is `uid = group_key`, an index scan.
- **What is grouped and what is not.** `photos_page`, `photos_months` and `photos_counts` count groups, except on the Raw tab, which lists files. Album pages are never grouped: an album is a list someone made. The relation is learned-and-kept like `media_type`, so a refresh that could not resolve a node does not break a group up.
- **Deleting.** `Core::trash_photos` expands each uid to its group before trashing, because the grid shows the shot as one tile — leaving the RAW behind would put the photo back on the Raw tab and nowhere else. The GUI's confirmation names the file count.
- **The other files.** `Request::PhotoGroup { uid }` answers with the group's members as ordinary `PhotoItem`s, which is what the lightbox's switch steps through.
### Persistent SDK Entity Cache (Implemented) ### 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) **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)