M1: retain selection after permanent photo deletion

Reuse the landed current-view deletion selection logic for photo.deletePermanently, retaining survivors or choosing the next neighbour and the previous at the end. Preserve catalog-only deletion and existing undo/persistence behavior.

Refs #441, #451, #545.
This commit is contained in:
midasdf
2026-10-10 03:24:49 +09:00
parent 88ea1a804b
commit c0ff290e7e
5 changed files with 129 additions and 13 deletions
+17 -12
View File
@@ -7,6 +7,20 @@ use serde_json::{Value, json};
use super::{CommandSpec, always, bad, bool_or, cmd, has_active, has_selection, ok, str_param};
use crate::{LibrarySource, Result, Selection, Session};
fn select_after_deletion(s: &mut Session, before: &[PhotoId]) {
let at = before.iter().position(|id| Some(*id) == s.selection.active);
let remaining: std::collections::HashSet<_> = s.visible_cloned().into_iter().collect();
s.selection.ids.retain(|id| remaining.contains(id));
s.selection.active = s.selection.active.filter(|id| remaining.contains(id)).or_else(|| s.selection.ids.first().copied());
if s.selection.active.is_none()
&& let Some(at) = at
{
// Keep culling near the deleted photo: next visible survivor, or the previous one at the end.
let neighbour = before.iter().skip(at.saturating_add(1)).chain(before.iter().take(at).rev()).find(|id| remaining.contains(id));
s.selection = neighbour.map(|id| Selection::single(*id)).unwrap_or_default();
}
}
/// The auto-import folder's visible files with their sizes (the file-system half of
/// `library.autoImportScan`; the app lists on a worker thread and passes `listing`).
pub fn list_auto_import_folder(folder: &str) -> std::result::Result<Vec<(String, u64)>, String> {
@@ -662,18 +676,8 @@ pub fn specs() -> Vec<CommandSpec> {
// ---- delete / restore
cmd!("photo.delete", "Delete Photo", ["Photo"], Some("Delete"), "{ids?} — moves to Recently Deleted", has_selection, |s, p| {
let before = s.visible_cloned();
let at = before.iter().position(|id| Some(*id) == s.selection.active);
let v = for_targets(s, p, "Delete", |id| Some(Op::SetDeleted { id, deleted: true }))?;
let remaining: std::collections::HashSet<_> = s.visible_cloned().into_iter().collect();
s.selection.ids.retain(|id| remaining.contains(id));
s.selection.active = s.selection.active.filter(|id| remaining.contains(id)).or_else(|| s.selection.ids.first().copied());
if s.selection.active.is_none()
&& let Some(at) = at
{
// Keep culling near the deleted photo: next visible survivor, or the previous one at the end.
let neighbour = before.iter().skip(at.saturating_add(1)).chain(before.iter().take(at).rev()).find(|id| remaining.contains(id));
s.selection = neighbour.map(|id| Selection::single(*id)).unwrap_or_default();
}
select_after_deletion(s, &before);
Ok(v)
}),
cmd!("photo.restore", "Restore", ["Photo"], None, "{ids?}", has_selection, |s, p| for_targets(s, p, "Restore", |id| Some(Op::SetDeleted {
@@ -699,10 +703,11 @@ pub fn specs() -> Vec<CommandSpec> {
}
),
cmd!("photo.deletePermanently", "Delete Permanently", ["Photo"], None, "{ids?}", has_selection, |s, p| {
let before = s.visible_cloned();
let t = s.targets(p);
let op = s.catalog.delete_photos_permanently_ops(&t);
s.commit("Delete Permanently", op)?;
s.selection = Selection::default();
select_after_deletion(s, &before);
Ok(json!({"deleted": t.len()}))
}),
// ---- metadata
+54
View File
@@ -198,6 +198,60 @@ fn deleting_explicit_targets_keeps_the_surviving_selection() {
assert_eq!(s.selection, Selection::single(visible[8]), "partial deletion retains a selected survivor");
}
#[test]
fn permanently_deleting_photos_selects_a_neighbour_in_recently_deleted() {
for params in [json!({}), json!({"key": "fileName", "ascending": false}), json!({"key": "random", "seed": 123})] {
for at_end in [false, true] {
let mut s = demo();
s.execute("library.selectAll", &json!({})).unwrap();
s.execute("photo.delete", &json!({})).unwrap();
s.execute("library.source", &json!({"kind": "recentlyDeleted"})).unwrap();
s.execute("library.sort", &params).unwrap();
s.execute("library.filter", &json!({"rating": 2})).unwrap();
let visible = s.visible_cloned();
assert!(visible.len() > 5);
let at = if at_end { visible.len() - 2 } else { visible.len() / 2 };
let deleted = &visible[at..at + 2];
s.execute("library.select", &json!({"ids": deleted, "active": deleted[0]})).unwrap();
let snapshot = s.catalog.to_snapshot();
let undo_len = s.undo.len();
let r = s.execute("photo.deletePermanently", &json!({})).unwrap();
assert_eq!(r["deleted"], 2);
let expected = if at_end { visible[at - 1] } else { visible[at + 2] };
assert_eq!(s.selection, Selection::single(expected), "sort {params}, end {at_end}");
assert!(deleted.iter().all(|id| s.catalog.photo(*id).is_none()));
assert_eq!(s.undo.len(), undo_len + 1);
let removed = s.catalog.to_snapshot();
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.catalog.to_snapshot(), snapshot);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(s.catalog.to_snapshot(), removed);
}
}
let mut s = demo();
s.execute("library.selectAll", &json!({})).unwrap();
s.execute("photo.deletePermanently", &json!({})).unwrap();
assert!(s.visible().is_empty());
assert_eq!(s.selection, Selection::default());
}
#[test]
fn permanently_deleting_explicit_targets_keeps_the_surviving_selection() {
let mut s = demo();
let visible = s.visible_cloned();
s.execute("library.select", &json!({"ids": [visible[8], visible[9]], "active": visible[9]})).unwrap();
let before = s.selection.clone();
s.execute("photo.deletePermanently", &json!({"ids": [visible[3]]})).unwrap();
assert_eq!(s.selection, before, "deleting an unselected photo doesn't change selection");
s.execute("photo.deletePermanently", &json!({"ids": [visible[9]]})).unwrap();
assert_eq!(s.selection, Selection::single(visible[8]), "partial deletion retains a selected survivor");
let before = s.selection.clone();
let snapshot = s.catalog.to_snapshot();
assert!(s.execute("photo.deletePermanently", &json!({"ids": [999_999]})).is_err());
assert_eq!(s.selection, before, "a refused deletion doesn't change selection");
assert_eq!(s.catalog.to_snapshot(), snapshot);
}
#[test]
fn masks_crop_and_render() {
let mut s = demo();
+19
View File
@@ -974,8 +974,27 @@ fn emptying_trashed_photos_of_one_album_and_stack_leaves_nothing_dangling() {
fn deleting_several_photos_permanently_leaves_nothing_dangling() {
let (mut s, src, ids, al) = two_trashed_in_album_and_stack("trash-perm-refs");
s.execute("library.select", &json!({"ids": ids})).unwrap();
let files = [src.join("a.png"), src.join("b.png")].map(|path| {
let bytes = std::fs::read(&path).unwrap();
(path, bytes)
});
let snapshot = s.catalog.to_snapshot();
s.drain_log();
s.execute("photo.deletePermanently", &json!({"ids": ids})).unwrap();
assert!(s.catalog.album(lightcraft_catalog::AlbumId(al)).unwrap().photos.is_empty());
assert!(ids.iter().all(|i| s.catalog.photo(lightcraft_catalog::PhotoId(*i)).is_none()));
let removed = s.catalog.to_snapshot();
let log: String = s.drain_log().iter().map(lightcraft_catalog::Catalog::op_to_log_line).collect();
let mut reopened = lightcraft_catalog::Catalog::from_snapshot(&snapshot).unwrap();
reopened.replay(&log).unwrap();
assert_eq!(reopened.to_snapshot(), removed);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(s.catalog.to_snapshot(), snapshot, "one undo restores photos, album and stack");
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(s.catalog.to_snapshot(), removed);
for (path, bytes) in files {
assert_eq!(std::fs::read(path).unwrap(), bytes, "permanent deletion only removes catalog records");
}
let _ = std::fs::remove_dir_all(&src);
}