Skip to main content

mimick/library/
staging_view.rs

1//! Staging view for files opened via "Open With" or the `--upload` CLI flag.
2//!
3//! Presents externally-provided file paths in a masonry grid with selection
4//! checkboxes and Upload / Upload to Album actions. Reuses the existing
5//! `MasonryCanvas`, `LibraryAssetModel`, `AssetObject`, `ThumbnailCache`,
6//! and `spawn_enqueue` infrastructure.
7
8use std::cell::Cell;
9use std::path::PathBuf;
10use std::rc::Rc;
11use std::sync::Arc;
12
13use glib::clone;
14use gtk::prelude::*;
15use libadwaita::prelude::*;
16
17use crate::api_client::LibraryAlbum;
18use crate::app_context::AppContext;
19use crate::library::asset_object::AssetObject;
20use crate::library::masonry::build_grid_view;
21use crate::library::style;
22use crate::library::upload_picker;
23use crate::media_kinds;
24
25/// Build and present the staging window populated with the given file paths.
26///
27/// When `auto_upload` is true (the `--upload` Desktop Action), an album
28/// picker dialog is presented immediately after the window opens.
29pub fn build_staging_window(
30    app: &libadwaita::Application,
31    ctx: Arc<AppContext>,
32    files: Vec<PathBuf>,
33    auto_upload: bool,
34) {
35    style::ensure_registered();
36    crate::library::register_app_icons();
37
38    let window = libadwaita::ApplicationWindow::builder()
39        .application(app)
40        .title("Mimick - Upload")
41        .name("mimick-staging-window")
42        .default_width(960)
43        .default_height(640)
44        .width_request(360)
45        .height_request(400)
46        .build();
47
48    let (upload_btn, upload_album_btn, select_all_btn, grid, status_label) =
49        build_staging_ui(&window, &ctx, &files);
50    setup_narrow_breakpoint(&window, &grid);
51
52    let file_count = grid.model.n_items();
53    let files_rc = Rc::new(files);
54    let selection = grid.selection.clone();
55
56    connect_select_all(&select_all_btn, &selection, &status_label, file_count);
57    connect_selection_tracking(&selection, &status_label);
58    connect_upload_buttons(
59        &upload_btn,
60        &upload_album_btn,
61        &ctx,
62        &selection,
63        &files_rc,
64        &window,
65    );
66
67    // Accept additional file drops onto the staging window.
68    connect_staging_drop_target(&window, &grid.model, &status_label);
69
70    window.present();
71
72    if auto_upload {
73        let all_paths = files_rc.to_vec();
74        selection.select_all();
75        glib::idle_add_local_once(clone!(
76            #[strong]
77            window,
78            #[strong]
79            ctx,
80            move || {
81                show_album_picker(window, ctx, all_paths);
82            }
83        ));
84    }
85}
86
87/// Build header, grid, toolbar, status bar and return the interactive widgets.
88fn build_staging_ui(
89    window: &libadwaita::ApplicationWindow,
90    ctx: &Arc<AppContext>,
91    files: &[PathBuf],
92) -> (
93    gtk::Button,
94    gtk::Button,
95    gtk::Button,
96    crate::library::masonry::grid_view::GridViewParts,
97    gtk::Label,
98) {
99    let (header, upload_btn, upload_album_btn, select_all_btn) = build_staging_header();
100    let toolbar = libadwaita::ToolbarView::builder().build();
101    toolbar.add_top_bar(&header);
102
103    let select_toggle = gtk::ToggleButton::builder().active(true).build();
104    let narrow = Rc::new(Cell::new(false));
105    let grid = build_grid_view(ctx.clone(), select_toggle.clone(), narrow.clone());
106    grid.canvas.set_select_mode(true);
107    grid.model.reset_with_objects(build_staging_assets(files));
108
109    let status_label = gtk::Label::builder()
110        .halign(gtk::Align::Start)
111        .hexpand(true)
112        .margin_start(12)
113        .margin_end(12)
114        .margin_top(4)
115        .margin_bottom(4)
116        .build();
117    status_label.set_text(&status_text(0, grid.model.n_items()));
118
119    toolbar.set_content(Some(&grid.scrolled));
120    toolbar.add_bottom_bar(&status_label);
121    window.set_content(Some(&toolbar));
122
123    (
124        upload_btn,
125        upload_album_btn,
126        select_all_btn,
127        grid,
128        status_label,
129    )
130}
131
132/// Build the staging header bar with upload, album, and select-all buttons.
133fn build_staging_header() -> (libadwaita::HeaderBar, gtk::Button, gtk::Button, gtk::Button) {
134    let header = libadwaita::HeaderBar::builder()
135        .show_start_title_buttons(true)
136        .show_end_title_buttons(true)
137        .build();
138    let upload_btn = gtk::Button::builder()
139        .label("Upload")
140        .tooltip_text("Upload selected files to library")
141        .css_classes(["suggested-action", "mimick-pressable"])
142        .build();
143    let album_btn = gtk::Button::builder()
144        .label("Album")
145        .tooltip_text("Upload selected files to an album")
146        .css_classes(["mimick-pressable"])
147        .build();
148    let select_btn = gtk::Button::builder()
149        .icon_name("edit-select-all-symbolic")
150        .tooltip_text("Select all / Deselect all")
151        .css_classes(["mimick-pressable"])
152        .build();
153    header.pack_start(&upload_btn);
154    header.pack_start(&album_btn);
155    header.pack_end(&select_btn);
156    (header, upload_btn, album_btn, select_btn)
157}
158
159/// Install a 600sp breakpoint to toggle narrow grid layout.
160fn setup_narrow_breakpoint(
161    window: &libadwaita::ApplicationWindow,
162    grid: &crate::library::masonry::grid_view::GridViewParts,
163) {
164    let bp = libadwaita::Breakpoint::new(
165        libadwaita::BreakpointCondition::parse("max-width: 600sp")
166            .expect("valid breakpoint condition"),
167    );
168    let c1 = grid.canvas.clone();
169    bp.connect_apply(move |_| c1.set_narrow(true));
170    let c2 = grid.canvas.clone();
171    bp.connect_unapply(move |_| c2.set_narrow(false));
172    window.add_breakpoint(bp);
173}
174
175fn connect_select_all(
176    btn: &gtk::Button,
177    selection: &gtk::MultiSelection,
178    status_label: &gtk::Label,
179    file_count: u32,
180) {
181    let sel = selection.clone();
182    let lbl = status_label.clone();
183    let n = file_count;
184    let all_selected = Rc::new(Cell::new(false));
185    btn.connect_clicked(move |btn| {
186        if all_selected.get() {
187            sel.unselect_all();
188            btn.set_icon_name("edit-select-all-symbolic");
189            btn.set_tooltip_text(Some("Select all"));
190            all_selected.set(false);
191            lbl.set_text(&status_text(0, n));
192        } else {
193            sel.select_all();
194            btn.set_icon_name("edit-clear-all-symbolic");
195            btn.set_tooltip_text(Some("Deselect all"));
196            all_selected.set(true);
197            lbl.set_text(&status_text(n, n));
198        }
199    });
200}
201
202fn connect_selection_tracking(selection: &gtk::MultiSelection, status_label: &gtk::Label) {
203    let sel = selection.clone();
204    let lbl = status_label.clone();
205    sel.connect_selection_changed(move |sel, _, _| {
206        let total = sel.n_items();
207        let selected = count_selected(sel);
208        lbl.set_text(&status_text(selected, total));
209    });
210}
211
212/// Format the status bar text for selected/total counts.
213fn status_text(selected: u32, total: u32) -> String {
214    if selected == 0 {
215        format!("{} file(s) staged", total)
216    } else {
217        format!("{} of {} selected", selected, total)
218    }
219}
220
221/// Wire both upload buttons; they share identical clone setup, differing only in action.
222fn connect_upload_buttons(
223    upload_btn: &gtk::Button,
224    album_btn: &gtk::Button,
225    ctx: &Arc<AppContext>,
226    selection: &gtk::MultiSelection,
227    files_rc: &Rc<Vec<PathBuf>>,
228    window: &libadwaita::ApplicationWindow,
229) {
230    let (sel1, files1, ctx1, win1) = (
231        selection.clone(),
232        files_rc.clone(),
233        ctx.clone(),
234        window.clone(),
235    );
236    upload_btn.connect_clicked(move |_| {
237        let paths = selected_paths(&sel1, &files1);
238        if paths.is_empty() {
239            return;
240        }
241        let w = win1.clone();
242        upload_picker::spawn_enqueue_with_callback(
243            ctx1.clone(),
244            None,
245            paths,
246            move |queued, skipped| show_upload_result(&w, queued, skipped),
247        );
248    });
249
250    let (sel2, files2, ctx2, win2) = (
251        selection.clone(),
252        files_rc.clone(),
253        ctx.clone(),
254        window.clone(),
255    );
256    album_btn.connect_clicked(move |_| {
257        let paths = selected_paths(&sel2, &files2);
258        if paths.is_empty() {
259            return;
260        }
261        show_album_picker(win2.clone(), ctx2.clone(), paths);
262    });
263}
264
265/// Build `AssetObject` entries from local file paths for the staging grid.
266fn build_staging_assets(files: &[PathBuf]) -> Vec<AssetObject> {
267    files
268        .iter()
269        .filter(|path| media_kinds::is_supported_path(path))
270        .filter_map(|path| {
271            let filename = path.file_name()?.to_string_lossy().to_string();
272            let mime = media_kinds::mime_for_path(path);
273            let kind = media_kinds::asset_kind(mime);
274            let asset_type = match kind {
275                media_kinds::AssetKind::Image => "IMAGE",
276                media_kinds::AssetKind::Video => "VIDEO",
277            };
278            let id = format!("local::{}", path.display());
279            let mtime_str = std::fs::metadata(path)
280                .ok()
281                .and_then(|m| m.modified().ok())
282                .map(systemtime_to_iso)
283                .unwrap_or_default();
284
285            Some(AssetObject::new_local(
286                &id,
287                &filename,
288                mime,
289                &mtime_str,
290                asset_type,
291                &path.to_string_lossy(),
292            ))
293        })
294        .collect()
295}
296
297/// Format a `SystemTime` as an ISO 8601 string in the local timezone.
298fn systemtime_to_iso(t: std::time::SystemTime) -> String {
299    use chrono::{DateTime, Local};
300    let dt: DateTime<Local> = t.into();
301    dt.to_rfc3339()
302}
303
304/// Count the number of selected items in a `MultiSelection`.
305fn count_selected(sel: &gtk::MultiSelection) -> u32 {
306    let bs = sel.selection();
307    let mut count = 0u32;
308    let n = bs.size();
309    for i in 0..n {
310        if bs.nth(i as u32) != gtk::INVALID_LIST_POSITION {
311            count += 1;
312        }
313    }
314    // Fallback: iterate all items
315    if count == 0 && n > 0 {
316        count = n as u32;
317    }
318    count
319}
320
321/// Collect file paths for the selected items, or all items if none selected.
322fn selected_paths(sel: &gtk::MultiSelection, files: &[PathBuf]) -> Vec<PathBuf> {
323    let bs = sel.selection();
324    let total = sel.n_items();
325    let mut paths = Vec::new();
326
327    // Collect indices of selected items.
328    let mut selected_indices = Vec::new();
329    let n_ranges = bs.size();
330    if n_ranges > 0 {
331        for i in 0..total {
332            if sel.is_selected(i) {
333                selected_indices.push(i as usize);
334            }
335        }
336    }
337
338    if selected_indices.is_empty() {
339        // Nothing explicitly selected -- upload all.
340        return files
341            .iter()
342            .filter(|p| media_kinds::is_supported_path(p))
343            .cloned()
344            .collect();
345    }
346
347    for idx in selected_indices {
348        if let Some(item) = sel.item(idx as u32).and_downcast::<AssetObject>() {
349            let local_path = item.property::<String>("local-path");
350            if !local_path.is_empty() {
351                paths.push(PathBuf::from(local_path));
352            }
353        }
354    }
355    paths
356}
357
358/// Present an album picker dialog and upload files to the chosen album.
359pub(crate) fn show_album_picker(
360    parent: libadwaita::ApplicationWindow,
361    ctx: Arc<AppContext>,
362    paths: Vec<PathBuf>,
363) {
364    let dialog = simple_alert("Upload to Album", "Loading albums...", "cancel", "Cancel");
365    dialog.present(Some(&parent));
366
367    let ctx_c = ctx.clone();
368    let parent_c = parent.clone();
369    glib::MainContext::default().spawn_local(async move {
370        let albums = match ctx_c.api_client.fetch_library_albums().await {
371            Ok(a) => a,
372            Err(err) => {
373                dialog.force_close();
374                simple_alert("Failed to Load Albums", &err, "ok", "OK").present(Some(&parent_c));
375                return;
376            }
377        };
378        dialog.force_close();
379        present_album_list(parent_c, ctx_c, albums, paths);
380    });
381}
382
383/// Build and show the album selection list dialog.
384fn present_album_list(
385    parent: libadwaita::ApplicationWindow,
386    ctx: Arc<AppContext>,
387    albums: Vec<LibraryAlbum>,
388    paths: Vec<PathBuf>,
389) {
390    let parent_for_result = parent.clone();
391    let dialog = libadwaita::AlertDialog::builder()
392        .heading("Select Album")
393        .body("Choose an album for the upload:")
394        .build();
395    dialog.add_response("cancel", "Cancel");
396
397    let (scroll, listbox) = build_album_listbox(&albums);
398    dialog.set_extra_child(Some(&scroll));
399
400    let albums_rc = Rc::new(albums);
401    let paths_rc = Rc::new(paths);
402    let ctx_c = ctx.clone();
403    dialog.connect_response(None, move |_dialog, response| {
404        if response == "cancel" {
405            return;
406        }
407        handle_album_upload(&listbox, &albums_rc, &ctx_c, &paths_rc, &parent_for_result);
408    });
409
410    dialog.add_response("upload", "Upload");
411    dialog.set_response_appearance("upload", libadwaita::ResponseAppearance::Suggested);
412    dialog.set_default_response(Some("upload"));
413    dialog.present(Some(&parent));
414}
415
416/// Handle the album upload response: look up selected row and enqueue.
417fn handle_album_upload(
418    listbox: &gtk::ListBox,
419    albums: &[LibraryAlbum],
420    ctx: &Arc<AppContext>,
421    paths: &Rc<Vec<PathBuf>>,
422    parent: &libadwaita::ApplicationWindow,
423) {
424    let Some(row) = listbox.selected_row() else {
425        return;
426    };
427    let idx = row.index() as usize;
428    if let Some(album) = albums.get(idx) {
429        let album_arg = Some((album.id.clone(), album.album_name.clone()));
430        let parent_c = parent.clone();
431        upload_picker::spawn_enqueue_with_callback(
432            ctx.clone(),
433            album_arg,
434            paths.to_vec(),
435            move |queued, skipped| show_upload_result(&parent_c, queued, skipped),
436        );
437    }
438}
439
440/// Build the scrollable album list for the picker dialog.
441fn build_album_listbox(albums: &[LibraryAlbum]) -> (gtk::ScrolledWindow, gtk::ListBox) {
442    let listbox = gtk::ListBox::builder()
443        .selection_mode(gtk::SelectionMode::Single)
444        .css_classes(["boxed-list"])
445        .build();
446    for album in albums {
447        let row = libadwaita::ActionRow::builder()
448            .title(&album.album_name)
449            .subtitle(format!("{} assets", album.asset_count))
450            .activatable(true)
451            .build();
452        listbox.append(&row);
453    }
454    let scroll = gtk::ScrolledWindow::builder()
455        .child(&listbox)
456        .min_content_height(200)
457        .max_content_height(400)
458        .hscrollbar_policy(gtk::PolicyType::Never)
459        .build();
460    (scroll, listbox)
461}
462
463/// Show a result dialog after upload enqueue completes.
464fn show_upload_result(parent: &libadwaita::ApplicationWindow, queued: usize, skipped: usize) {
465    let (heading, body) = if skipped == 0 {
466        (
467            "Upload Queued",
468            format!(
469                "{} file(s) queued for upload. Progress is visible in the queue inspector.",
470                queued
471            ),
472        )
473    } else if queued == 0 {
474        (
475            "Upload Failed",
476            format!(
477                "All {} file(s) failed to enqueue. Check the logs for details.",
478                skipped
479            ),
480        )
481    } else {
482        (
483            "Upload Partially Queued",
484            format!(
485                "{} file(s) queued, {} skipped (hash or path errors).",
486                queued, skipped
487            ),
488        )
489    };
490
491    simple_alert(heading, &body, "ok", "OK").present(Some(parent));
492}
493
494/// Create a one-button `AlertDialog` (shared pattern used by multiple dialogs).
495fn simple_alert(
496    heading: &str,
497    body: &str,
498    response_id: &str,
499    response_label: &str,
500) -> libadwaita::AlertDialog {
501    let dialog = libadwaita::AlertDialog::builder()
502        .heading(heading)
503        .body(body)
504        .build();
505    dialog.add_response(response_id, response_label);
506    dialog
507}
508
509/// Attach a `DropTarget` to the staging window for appending additional files.
510fn connect_staging_drop_target(
511    window: &libadwaita::ApplicationWindow,
512    model: &crate::library::asset_model::LibraryAssetModel,
513    status_label: &gtk::Label,
514) {
515    use std::collections::HashSet;
516
517    let drop_target = gtk::DropTarget::new(
518        gtk::gdk::FileList::static_type(),
519        gtk::gdk::DragAction::COPY,
520    );
521
522    let model = model.clone();
523    let label = status_label.clone();
524    drop_target.connect_drop(move |_target, value, _x, _y| {
525        let file_list = match value.get::<gtk::gdk::FileList>() {
526            Ok(fl) => fl,
527            Err(_) => return false,
528        };
529
530        // Collect existing paths to deduplicate.
531        let mut existing: HashSet<String> = HashSet::new();
532        for i in 0..model.n_items() {
533            if let Some(obj) = model.item(i).and_downcast::<AssetObject>() {
534                let lp = obj.property::<String>("local-path");
535                if !lp.is_empty() {
536                    existing.insert(lp);
537                }
538            }
539        }
540
541        let new_paths: Vec<PathBuf> = file_list
542            .files()
543            .iter()
544            .filter_map(|f| f.path())
545            .filter(|p| media_kinds::is_supported_path(p))
546            .filter(|p| !existing.contains(&p.to_string_lossy().to_string()))
547            .collect();
548
549        if new_paths.is_empty() {
550            return true;
551        }
552
553        let new_assets = build_staging_assets(&new_paths);
554        model.append_objects(&new_assets);
555
556        let total = model.n_items();
557        label.set_text(&status_text(0, total));
558        true
559    });
560
561    window.add_controller(drop_target);
562}
563
564#[cfg(test)]
565mod tests {
566    use super::*;
567
568    #[test]
569    fn build_staging_assets_filters_unsupported() {
570        let files = vec![
571            PathBuf::from("/tmp/photo.jpg"),
572            PathBuf::from("/tmp/document.pdf"),
573            PathBuf::from("/tmp/video.mp4"),
574            PathBuf::from("/tmp/readme.txt"),
575        ];
576        let assets = build_staging_assets(&files);
577        // .jpg and .mp4 are supported; .pdf and .txt are not.
578        assert_eq!(assets.len(), 2);
579        assert_eq!(assets[0].property::<String>("filename"), "photo.jpg");
580        assert_eq!(assets[1].property::<String>("filename"), "video.mp4");
581    }
582
583    #[test]
584    fn build_staging_assets_sets_asset_type() {
585        let files = vec![
586            PathBuf::from("/tmp/photo.png"),
587            PathBuf::from("/tmp/clip.mkv"),
588        ];
589        let assets = build_staging_assets(&files);
590        assert_eq!(assets.len(), 2);
591        assert_eq!(assets[0].property::<String>("asset-type"), "IMAGE");
592        assert_eq!(assets[1].property::<String>("asset-type"), "VIDEO");
593    }
594}