Skip to main content

mimick/library/
trash_view.rs

1//! Trash view controls: restore or permanently delete trashed assets.
2//!
3//! The Trash sidebar destination lists trashed assets in the normal grid. This
4//! module adds the "Restore All" / "Empty Trash" bar above the grid and swaps the
5//! selection bar's actions for "Restore" / "Delete Permanently" while it's active.
6
7use std::rc::Rc;
8
9use gtk::prelude::*;
10use libadwaita::prelude::*;
11
12use super::LibraryWindowUi;
13use super::actions::{TrashTarget, remove_assets_from_grid, selected_items};
14use super::context_menu::show_alert_dialog;
15use super::state::LibrarySource;
16
17/// Trash-only widgets, plus the selection-bar buttons they temporarily replace.
18pub(super) struct TrashControls {
19    /// Bar above the grid; revealed only while the Trash source is active.
20    pub bar: gtk::Revealer,
21    restore_all: gtk::Button,
22    empty: gtk::Button,
23    /// Selection-bar actions shown in the trash.
24    pub restore_selected: gtk::Button,
25    pub delete_selected: gtk::Button,
26    /// Selection-bar actions hidden in the trash (download, move to trash).
27    live_only: Vec<gtk::Button>,
28}
29
30/// Bar above the grid in the Trash: the retention note with Restore All and Empty Trash.
31fn build_trash_bar(restore_all: &gtk::Button, empty: &gtk::Button) -> gtk::Revealer {
32    let note = gtk::Label::builder()
33        .label("Immich permanently deletes trashed items after its retention period (30 days by default).")
34        .xalign(0.0)
35        .hexpand(true)
36        .wrap(true)
37        .css_classes(["dim-label"])
38        .build();
39    let buttons = gtk::Box::builder()
40        .orientation(gtk::Orientation::Horizontal)
41        .spacing(8)
42        .halign(gtk::Align::End)
43        .build();
44    buttons.append(restore_all);
45    buttons.append(empty);
46    // Note above the buttons: the bar's width counts even while hidden, and side by side
47    // they don't fit a 360 px window.
48    let inner = gtk::Box::builder()
49        .orientation(gtk::Orientation::Vertical)
50        .spacing(6)
51        .margin_top(6)
52        .margin_bottom(6)
53        .margin_start(12)
54        .margin_end(12)
55        .build();
56    inner.append(&note);
57    inner.append(&buttons);
58    gtk::Revealer::builder()
59        .transition_type(gtk::RevealerTransitionType::SlideDown)
60        .reveal_child(false)
61        .child(&inner)
62        .build()
63}
64
65pub(super) fn build_trash_controls(live_only: Vec<gtk::Button>) -> TrashControls {
66    let restore_all = gtk::Button::builder().label("Restore All").build();
67    let empty = gtk::Button::builder()
68        .label("Empty Trash")
69        .css_classes(["destructive-action"])
70        .build();
71    let bar = build_trash_bar(&restore_all, &empty);
72    let restore_selected = gtk::Button::builder()
73        .icon_name("edit-undo-symbolic")
74        .tooltip_text("Restore selected")
75        .visible(false)
76        .build();
77    let delete_selected = gtk::Button::builder()
78        .icon_name("edit-delete-symbolic")
79        .tooltip_text("Delete selected permanently")
80        .css_classes(["destructive-action"])
81        .visible(false)
82        .build();
83    TrashControls {
84        bar,
85        restore_all,
86        empty,
87        restore_selected,
88        delete_selected,
89        live_only,
90    }
91}
92
93pub(super) fn is_trash_active(ui: &LibraryWindowUi) -> bool {
94    matches!(ui.ctx.library_state.lock().source, LibrarySource::Trash)
95}
96
97/// Show the trash bar and trash selection actions only while the Trash source is active.
98pub(super) fn set_trash_mode(ui: &LibraryWindowUi, active: bool) {
99    let trash = &ui.trash;
100    trash.bar.set_reveal_child(active);
101    trash.restore_selected.set_visible(active);
102    trash.delete_selected.set_visible(active);
103    for button in &trash.live_only {
104        button.set_visible(!active);
105    }
106}
107
108pub(super) fn connect_trash_controls(ui: Rc<LibraryWindowUi>) {
109    let ui_restore_all = ui.clone();
110    ui.trash
111        .restore_all
112        .connect_clicked(move |_| restore_all(ui_restore_all.clone()));
113    let ui_empty = ui.clone();
114    ui.trash
115        .empty
116        .connect_clicked(move |_| confirm_empty_trash(ui_empty.clone()));
117    let ui_restore = ui.clone();
118    ui.trash
119        .restore_selected
120        .connect_clicked(move |_| restore_selected(ui_restore.clone()));
121    let ui_delete = ui.clone();
122    ui.trash
123        .delete_selected
124        .connect_clicked(move |_| confirm_delete_selected(ui_delete.clone()));
125}
126
127fn selected_ids(ui: &Rc<LibraryWindowUi>) -> Vec<String> {
128    selected_items(ui)
129        .iter()
130        .filter_map(TrashTarget::from_item)
131        .map(|target| target.remote_id().to_string())
132        .collect()
133}
134
135fn clear_selection(ui: &LibraryWindowUi) {
136    ui.grid.selection.unselect_all();
137    ui.select_toggle.set_active(false);
138}
139
140fn restore_selected(ui: Rc<LibraryWindowUi>) {
141    let ids = selected_ids(&ui);
142    let ui_done = ui.clone();
143    restore(ui, ids, move || clear_selection(&ui_done));
144}
145
146fn confirm_delete_selected(ui: Rc<LibraryWindowUi>) {
147    let ids = selected_ids(&ui);
148    let ui_done = ui.clone();
149    confirm_delete_permanently(ui, ids, move || clear_selection(&ui_done));
150}
151
152/// Restore assets from the trash and drop them from the trash grid; `on_done` runs after.
153pub(super) fn restore(ui: Rc<LibraryWindowUi>, ids: Vec<String>, on_done: impl Fn() + 'static) {
154    if ids.is_empty() {
155        return;
156    }
157    glib::MainContext::default().spawn_local(async move {
158        match ui.ctx.api_client.restore_assets(&ids).await {
159            Ok(_) => {
160                remove_assets_from_grid(&ui, &ids);
161                on_done();
162            }
163            Err(err) => show_alert_dialog(&ui, "Could Not Restore", &err),
164        }
165    });
166}
167
168/// Heading and body for the permanent-delete confirmation.
169fn permanent_delete_text(count: usize) -> (String, String) {
170    let heading = if count == 1 {
171        "Delete this item permanently?".to_string()
172    } else {
173        format!("Delete {count} items permanently?")
174    };
175    (
176        heading,
177        "This can't be undone. Local copies in your watch folders are kept.".to_string(),
178    )
179}
180
181/// Ask, then permanently delete `ids` and drop them from the grid; `on_done` runs after.
182pub(super) fn confirm_delete_permanently(
183    ui: Rc<LibraryWindowUi>,
184    ids: Vec<String>,
185    on_done: impl Fn() + 'static,
186) {
187    if ids.is_empty() {
188        return;
189    }
190    let (heading, body) = permanent_delete_text(ids.len());
191    let on_done = Rc::new(on_done);
192    confirm_destructive(&ui, &heading, &body, "Delete Permanently", move |ui| {
193        let ids = ids.clone();
194        let on_done = on_done.clone();
195        glib::MainContext::default().spawn_local(async move {
196            match ui.ctx.api_client.delete_assets_permanently(&ids).await {
197                Ok(()) => {
198                    remove_assets_from_grid(&ui, &ids);
199                    on_done();
200                }
201                Err(err) => show_alert_dialog(&ui, "Could Not Delete", &err),
202            }
203        });
204    });
205}
206
207fn restore_all(ui: Rc<LibraryWindowUi>) {
208    glib::MainContext::default().spawn_local(async move {
209        match ui.ctx.api_client.restore_all_trash().await {
210            Ok(_) => super::refresh_library_after_mutation(ui.clone(), true),
211            Err(err) => show_alert_dialog(&ui, "Could Not Restore", &err),
212        }
213    });
214}
215
216fn confirm_empty_trash(ui: Rc<LibraryWindowUi>) {
217    confirm_destructive(
218        &ui,
219        "Empty the trash?",
220        "Everything in the Immich trash is deleted permanently. This can't be undone.",
221        "Empty Trash",
222        |ui| {
223            glib::MainContext::default().spawn_local(async move {
224                match ui.ctx.api_client.empty_trash().await {
225                    Ok(_) => super::refresh_library_after_mutation(ui.clone(), true),
226                    Err(err) => show_alert_dialog(&ui, "Could Not Empty Trash", &err),
227                }
228            });
229        },
230    );
231}
232
233/// Cancel-by-default confirmation whose destructive button runs `on_confirm`.
234fn confirm_destructive(
235    ui: &Rc<LibraryWindowUi>,
236    heading: &str,
237    body: &str,
238    action_label: &str,
239    on_confirm: impl Fn(Rc<LibraryWindowUi>) + 'static,
240) {
241    let dialog = libadwaita::AlertDialog::builder()
242        .heading(heading)
243        .body(body)
244        .build();
245    dialog.add_response("cancel", "Cancel");
246    dialog.add_response("confirm", action_label);
247    dialog.set_response_appearance("confirm", libadwaita::ResponseAppearance::Destructive);
248    dialog.set_default_response(Some("cancel"));
249    dialog.set_close_response("cancel");
250    let ui_for_choice = ui.clone();
251    dialog.connect_response(None, move |_, response| {
252        if response == "confirm" {
253            on_confirm(ui_for_choice.clone());
254        }
255    });
256    dialog.present(Some(&ui.window));
257}
258
259#[cfg(test)]
260mod tests {
261    use super::permanent_delete_text;
262
263    #[test]
264    fn permanent_delete_text_matches_count_and_warns() {
265        let (single, body) = permanent_delete_text(1);
266        assert_eq!(single, "Delete this item permanently?");
267        assert!(body.contains("can't be undone"));
268        assert_eq!(permanent_delete_text(3).0, "Delete 3 items permanently?");
269    }
270}