The scan/opening/status/error lines, offline mode and its retry, pinning a collection offline, the sync and thumbnail-sweep state, and the callbacks that route the grid to a rescan, a sync, another library, a panorama merge or an export — the last of what AppWindow still carried under the library- prefix — move onto the `Library` global started earlier on this branch. Rust reaches them through window.global::<Library>() rather than window.set_/get_/on_/invoke_ on the root. library-visible is the one name that stays: it is computed from active-page and active-view, the shell's own routing state, which a global cannot read. AppWindow now declares no other library- property or callback.
310 lines
11 KiB
Rust
310 lines
11 KiB
Rust
//! Soft delete: moving images to the trash on the server, restoring them,
|
|
//! and the worker drain both share.
|
|
//!
|
|
//! A drop is a *soft delete*: the file moves to a trash folder on the
|
|
//! server and the catalog records where it came from. Nothing is
|
|
//! destroyed until the user empties it, a separate, deliberate action.
|
|
|
|
use std::cell::RefCell;
|
|
use std::rc::Rc;
|
|
|
|
use dr_catalog::Catalog;
|
|
use dr_types::ImageId;
|
|
use slint::ComponentHandle;
|
|
|
|
use crate::{AppWindow, Collections, Library};
|
|
|
|
use super::controller::CollectionsController;
|
|
use super::tree_sync::refresh_tree;
|
|
|
|
/// Begin a soft delete: plan the moves, then hand them to a worker.
|
|
///
|
|
/// The plan is built here because it reads the catalog, which is not `Send`; the
|
|
/// worker gets paths and ids and needs no catalog to do its half.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub(super) fn start_trash(
|
|
window: &AppWindow,
|
|
ctl: &Rc<CollectionsController>,
|
|
catalog: &Rc<RefCell<Option<Catalog>>>,
|
|
session: &Rc<dyn Fn() -> Option<dr_sync::Connection>>,
|
|
images: &[ImageId],
|
|
reload: &Rc<dyn Fn()>,
|
|
) {
|
|
let Some(conn) = session() else {
|
|
window
|
|
.global::<Collections>()
|
|
.set_collection_error("Open a library first.".into());
|
|
return;
|
|
};
|
|
|
|
let moves = {
|
|
let borrow = catalog.borrow();
|
|
let Some(cat) = borrow.as_ref() else { return };
|
|
match crate::trash::plan_trash(cat, &conn.account.root, images) {
|
|
Ok(m) => m,
|
|
Err(e) => {
|
|
window
|
|
.global::<Collections>()
|
|
.set_collection_error(format!("planning delete: {e}").into());
|
|
return;
|
|
}
|
|
}
|
|
};
|
|
|
|
if moves.is_empty() {
|
|
return;
|
|
}
|
|
|
|
log::info!("moving {} image(s) to the trash", moves.len());
|
|
window
|
|
.global::<Library>()
|
|
.set_library_status(format!("Moving {} to the trash…", moves.len()).into());
|
|
// The images are leaving the grid; a selection pointing at them would
|
|
// survive as a set of ids the user can no longer see.
|
|
ctl.clear_selection();
|
|
|
|
let count = moves.len();
|
|
let rx = crate::trash::spawn_move(
|
|
conn.clone(),
|
|
moves,
|
|
crate::trash::Direction::ToTrash,
|
|
crate::library::catalog_path(&conn.account),
|
|
);
|
|
|
|
drain_trash(
|
|
window.as_weak(),
|
|
ctl.clone(),
|
|
catalog.clone(),
|
|
rx,
|
|
reload.clone(),
|
|
format!("Moving {count} photograph(s) to the trash"),
|
|
);
|
|
}
|
|
|
|
/// TRACES: FR-CAT-15
|
|
/// Put trashed images back where they came from.
|
|
///
|
|
/// The mirror of [`start_trash`], and separate from it rather than a `direction`
|
|
/// parameter on one function: the two differ in what they plan, what they report
|
|
/// and what they say when the plan comes back empty, and the shared part is the
|
|
/// three lines that spawn the worker.
|
|
///
|
|
/// An image whose origin was never recorded is skipped by
|
|
/// [`crate::trash::plan_restore`] rather than guessed at. That can make the plan
|
|
/// shorter than the selection, which is why an empty plan is reported here
|
|
/// instead of returning silently — the user pressed a button and is owed an
|
|
/// answer either way.
|
|
pub(super) fn start_restore(
|
|
window: &AppWindow,
|
|
ctl: &Rc<CollectionsController>,
|
|
catalog: &Rc<RefCell<Option<Catalog>>>,
|
|
session: &Rc<dyn Fn() -> Option<dr_sync::Connection>>,
|
|
images: &[ImageId],
|
|
reload: &Rc<dyn Fn()>,
|
|
) {
|
|
let Some(conn) = session() else {
|
|
window
|
|
.global::<Collections>()
|
|
.set_collection_error("Open a library first.".into());
|
|
return;
|
|
};
|
|
|
|
let moves = {
|
|
let borrow = catalog.borrow();
|
|
let Some(cat) = borrow.as_ref() else { return };
|
|
match crate::trash::plan_restore(cat, images) {
|
|
Ok(m) => m,
|
|
Err(e) => {
|
|
window
|
|
.global::<Collections>()
|
|
.set_collection_error(format!("planning restore: {e}").into());
|
|
return;
|
|
}
|
|
}
|
|
};
|
|
|
|
if moves.is_empty() {
|
|
// Said out loud rather than passed over in silence: a button that does
|
|
// nothing visible reads as broken, and the reason here is specific.
|
|
window.global::<Collections>().set_collection_error(
|
|
"Nothing to restore — no record of where these came from.".into(),
|
|
);
|
|
return;
|
|
}
|
|
|
|
log::info!("restoring {} image(s) from the trash", moves.len());
|
|
window
|
|
.global::<Library>()
|
|
.set_library_status(format!("Restoring {}…", moves.len()).into());
|
|
// The images are leaving the trash view, so a selection pointing at them
|
|
// would survive as ids the user can no longer see.
|
|
ctl.clear_selection();
|
|
|
|
let count = moves.len();
|
|
let rx = crate::trash::spawn_move(
|
|
conn.clone(),
|
|
moves,
|
|
crate::trash::Direction::Restore,
|
|
crate::library::catalog_path(&conn.account),
|
|
);
|
|
|
|
drain_trash(
|
|
window.as_weak(),
|
|
ctl.clone(),
|
|
catalog.clone(),
|
|
rx,
|
|
reload.clone(),
|
|
format!("Restoring {count} photograph(s)"),
|
|
);
|
|
}
|
|
|
|
/// Drain a trash worker on the UI thread.
|
|
///
|
|
/// Same shape as the scan and thumbnail drains: an mpsc channel polled by a
|
|
/// Slint timer, so nothing blocks the event loop (NFR-P9).
|
|
pub(super) fn drain_trash(
|
|
weak: slint::Weak<AppWindow>,
|
|
ctl: Rc<CollectionsController>,
|
|
catalog: Rc<RefCell<Option<Catalog>>>,
|
|
rx: std::sync::mpsc::Receiver<crate::trash::TrashMessage>,
|
|
reload: Rc<dyn Fn()>,
|
|
// What the register calls this operation. Passed in rather than derived
|
|
// here: the three callers move files to the trash, back out of it, and
|
|
// delete them outright, and "Deleting 40 photographs" is the one word of
|
|
// the three that must not appear over a restore.
|
|
title: String,
|
|
) {
|
|
use crate::trash::TrashMessage;
|
|
|
|
let timer = slint::Timer::default();
|
|
let ctl_cb = ctl.clone();
|
|
|
|
// A server-side MOVE per file, so it is a transfer in the sense that
|
|
// matters: it takes as long as the connection is slow, and it can fail
|
|
// halfway with the library in two states at once.
|
|
let job = ctl.activity.begin(crate::activity::Kind::Trash, title);
|
|
|
|
timer.start(
|
|
slint::TimerMode::Repeated,
|
|
std::time::Duration::from_millis(120),
|
|
move || {
|
|
let Some(w) = weak.upgrade() else { return };
|
|
|
|
loop {
|
|
let msg = match rx.try_recv() {
|
|
Ok(m) => m,
|
|
Err(std::sync::mpsc::TryRecvError::Empty) => return,
|
|
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
|
// A worker that died without reporting must not leave the
|
|
// status line mid-sentence.
|
|
job.fail("stopped without finishing");
|
|
stop_trash(&ctl_cb);
|
|
return;
|
|
}
|
|
};
|
|
|
|
match msg {
|
|
TrashMessage::Progress {
|
|
done,
|
|
total,
|
|
failed,
|
|
} => {
|
|
let status = if failed > 0 {
|
|
format!("{done} / {total} · {failed} failed")
|
|
} else {
|
|
format!("{done} / {total}")
|
|
};
|
|
job.progress(done, total);
|
|
if failed > 0 {
|
|
job.detail(format!("{failed} failed"));
|
|
}
|
|
w.global::<Library>().set_library_status(status.into());
|
|
}
|
|
TrashMessage::Done { moved, failed } => {
|
|
// Reported honestly, including the partial case: "38 of
|
|
// 40" is the truth when two files could not be moved,
|
|
// and claiming 40 would hide a real problem.
|
|
let status = if failed.is_empty() {
|
|
format!("{moved} image(s) done")
|
|
} else {
|
|
format!("{moved} done · {} failed", failed.len())
|
|
};
|
|
// A partial failure is a failure in the register: the
|
|
// library is now in two states at once, which is
|
|
// exactly the thing worth keeping on the list.
|
|
if failed.is_empty() {
|
|
job.finish(status.clone());
|
|
} else {
|
|
job.fail(status.clone());
|
|
}
|
|
w.global::<Library>().set_library_status(status.into());
|
|
if let Some(first) = failed.first() {
|
|
w.global::<Collections>()
|
|
.set_collection_error(first.as_str().into());
|
|
}
|
|
|
|
let borrow = catalog.borrow();
|
|
if let Some(cat) = borrow.as_ref() {
|
|
refresh_trash(&w, cat);
|
|
refresh_tree(&w, &ctl_cb, cat);
|
|
}
|
|
drop(borrow);
|
|
|
|
// The grid changed: images left the library, or came
|
|
// back into it.
|
|
reload();
|
|
stop_trash(&ctl_cb);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
},
|
|
);
|
|
|
|
*ctl.trash_timer.borrow_mut() = Some(timer);
|
|
}
|
|
|
|
fn stop_trash(ctl: &Rc<CollectionsController>) {
|
|
if let Some(t) = ctl.trash_timer.borrow().as_ref() {
|
|
t.stop();
|
|
}
|
|
}
|
|
|
|
/// Refresh the sidebar's trash count and size.
|
|
///
|
|
/// The size is formatted here rather than in Slint, which has no byte-size
|
|
/// formatting — and the number is what tells the user whether emptying is worth
|
|
/// it.
|
|
pub fn refresh_trash(window: &AppWindow, catalog: &Catalog) {
|
|
let (n, bytes) = dr_catalog::trash::summary(catalog.connection()).unwrap_or((0, 0));
|
|
window.global::<Collections>().set_trash_count(n as i32);
|
|
window.global::<Collections>().set_trash_label(if n == 0 {
|
|
slint::SharedString::new()
|
|
} else {
|
|
format!("{n} · {}", format_bytes(bytes)).into()
|
|
});
|
|
}
|
|
|
|
/// Bytes as a human-readable size.
|
|
///
|
|
/// Binary units, one decimal place past a kilobyte: a RAW library is measured in
|
|
/// gigabytes and "3.4 GB" is the figure a photographer reasons about, where
|
|
/// 3_650_722_201 is not.
|
|
fn format_bytes(bytes: u64) -> String {
|
|
const KB: f64 = 1024.0;
|
|
let b = bytes as f64;
|
|
if bytes < 1024 {
|
|
return format!("{bytes} B");
|
|
}
|
|
for (limit, unit) in [
|
|
(KB * KB, "kB"),
|
|
(KB * KB * KB, "MB"),
|
|
(KB * KB * KB * KB, "GB"),
|
|
] {
|
|
if b < limit {
|
|
return format!("{:.1} {unit}", b / (limit / KB));
|
|
}
|
|
}
|
|
format!("{:.1} TB", b / (KB * KB * KB * KB))
|
|
}
|