Skip to main content

mimick/settings_window/
watch_folders.rs

1//! Watch-folder row construction, folder-rules dialog, and album picker dialog.
2//!
3//! Each configured watch path gets a row with inline controls for the
4//! target album and a rules button. The rules dialog exposes sync method,
5//! extension and size filters, deletion policy, and hidden-file settings.
6
7use std::cell::RefCell;
8use std::rc::Rc;
9
10use adw::prelude::*;
11use glib::clone;
12use gtk::prelude::*;
13use gtk::{Box, Button, Entry, ListBox, Orientation, ScrolledWindow};
14use libadwaita as adw;
15
16use crate::config::{FolderRules, FolderSyncMethod, StartupCatchupMode};
17use crate::watch_path_display::{display_watch_path, watch_path_subtitle};
18
19use super::{DEFAULT_ALBUM_LABEL, FolderRowData, WatchPathEntry};
20
21/// Append a new watched folder row to the settings folders ListBox.
22pub(super) fn add_folder_row(
23    list: &ListBox,
24    entry: &WatchPathEntry,
25    fallback_catchup_mode: StartupCatchupMode,
26    albums_ref: Rc<RefCell<Vec<(String, String)>>>,
27    tracked_rows: &Rc<RefCell<Vec<FolderRowData>>>,
28    on_settings_changed: Rc<dyn Fn()>,
29) {
30    let path = entry.path().to_string();
31    let base_subtitle = watch_path_subtitle(&path).unwrap_or_default().to_string();
32
33    let mut initial_subtitle = base_subtitle.clone();
34    if !initial_subtitle.is_empty() {
35        initial_subtitle.push('\n');
36    }
37    initial_subtitle.push_str("Status: Idle");
38
39    let expander_row = adw::ExpanderRow::builder()
40        .title(display_watch_path(&path))
41        .subtitle(&initial_subtitle)
42        .subtitle_lines(2)
43        .title_lines(1)
44        .build();
45
46    let album_name = Rc::new(RefCell::new(
47        entry
48            .album_name()
49            .unwrap_or(DEFAULT_ALBUM_LABEL)
50            .to_string(),
51    ));
52
53    let rules = Rc::new(RefCell::new(entry.rules()));
54
55    let picker_btn = Button::builder()
56        .label(album_name.borrow().clone())
57        .valign(gtk::Align::Center)
58        .tooltip_text("Select or create a target Immich album")
59        .build();
60    if let Some(label) = picker_btn.child().and_downcast::<gtk::Label>() {
61        label.set_ellipsize(gtk::pango::EllipsizeMode::End);
62        label.set_max_width_chars(16);
63    }
64
65    let picker_btn_clone = picker_btn.clone();
66    let album_name_clone = album_name.clone();
67    let albums_ref_clone = albums_ref.clone();
68    let on_settings_changed_for_picker = on_settings_changed.clone();
69
70    picker_btn.connect_clicked(clone!(
71        #[weak]
72        expander_row,
73        move |_| {
74            if let Some(window) = expander_row
75                .root()
76                .and_then(|root| root.downcast::<gtk::Window>().ok())
77            {
78                let window_clone = window.clone();
79                let albums_ref_clone = albums_ref_clone.clone();
80                let album_name_clone = album_name_clone.clone();
81                let picker_btn_clone = picker_btn_clone.clone();
82                let on_settings_changed_for_picker = on_settings_changed_for_picker.clone();
83
84                glib::idle_add_local_once(move || {
85                    show_album_picker_dialog(
86                        &window_clone,
87                        albums_ref_clone,
88                        album_name_clone,
89                        picker_btn_clone,
90                        on_settings_changed_for_picker,
91                    );
92                });
93            }
94        }
95    ));
96
97    let album_subrow = adw::ActionRow::builder()
98        .title("Target Album")
99        .title_lines(1)
100        .build();
101    album_subrow.add_suffix(&picker_btn);
102    expander_row.add_row(&album_subrow);
103
104    let remove_btn = Button::builder()
105        .icon_name("user-trash-symbolic")
106        .valign(gtk::Align::Center)
107        .css_classes(vec!["destructive-action".to_string()])
108        .build();
109    let rules_btn = Button::builder()
110        .label("Rules")
111        .tooltip_text("Edit folder rules")
112        .valign(gtk::Align::Center)
113        .build();
114
115    let list_clone = list.clone();
116    let tracked_clone = tracked_rows.clone();
117    let path_clone = path.clone();
118    let rules_clone = rules.clone();
119    let path_for_rules = path.clone();
120    let on_settings_changed_for_rules = on_settings_changed.clone();
121    let fallback_catchup_mode_for_rules = fallback_catchup_mode.clone();
122
123    rules_btn.connect_clicked(clone!(
124        #[weak]
125        expander_row,
126        move |_| {
127            if let Some(window) = expander_row
128                .root()
129                .and_then(|root| root.downcast::<gtk::Window>().ok())
130            {
131                let window = window.clone();
132                let path_for_rules = path_for_rules.clone();
133                let rules_clone = rules_clone.clone();
134                let on_settings_changed_for_rules = on_settings_changed_for_rules.clone();
135                let fallback_catchup_mode_for_rules = fallback_catchup_mode_for_rules.clone();
136                glib::idle_add_local_once(move || {
137                    show_folder_rules_dialog(
138                        &window,
139                        &path_for_rules,
140                        fallback_catchup_mode_for_rules.clone(),
141                        rules_clone,
142                        on_settings_changed_for_rules,
143                    );
144                });
145            }
146        }
147    ));
148
149    let on_settings_changed_for_remove = on_settings_changed.clone();
150
151    remove_btn.connect_clicked(clone!(
152        #[weak]
153        expander_row,
154        move |_| {
155            let list_clone = list_clone.clone();
156            let tracked_clone = tracked_clone.clone();
157            let path_clone = path_clone.clone();
158            let expander_row = expander_row.clone();
159            let on_settings_changed_for_remove = on_settings_changed_for_remove.clone();
160            glib::idle_add_local_once(move || {
161                if let Some(focus_target) = list_clone.first_child() {
162                    focus_target.grab_focus();
163                }
164                list_clone.remove(&expander_row);
165                tracked_clone.borrow_mut().retain(|r| r.path != path_clone);
166                (on_settings_changed_for_remove)();
167            });
168        }
169    ));
170
171    let rules_subrow = adw::ActionRow::builder()
172        .title("Folder Rules")
173        .title_lines(1)
174        .build();
175    rules_subrow.add_suffix(&rules_btn);
176    expander_row.add_row(&rules_subrow);
177
178    let remove_subrow = adw::ActionRow::builder()
179        .title("Remove Folder")
180        .title_lines(1)
181        .build();
182    remove_subrow.add_suffix(&remove_btn);
183    expander_row.add_row(&remove_subrow);
184
185    list.append(&expander_row);
186    tracked_rows.borrow_mut().push(FolderRowData {
187        path,
188        album_name,
189        rules,
190        action_row: expander_row,
191        base_subtitle,
192    });
193}
194
195/// Present a modal dialog for editing a folder's sync and filter rules.
196fn show_folder_rules_dialog(
197    parent: &impl gtk::prelude::IsA<gtk::Window>,
198    folder_path: &str,
199    fallback_catchup_mode: StartupCatchupMode,
200    rules_state: Rc<RefCell<FolderRules>>,
201    on_settings_changed: Rc<dyn Fn()>,
202) {
203    let dialog = adw::Window::builder()
204        .transient_for(parent)
205        .modal(true)
206        .title("Folder Rules")
207        .default_width(380)
208        .default_height(640)
209        .width_request(360)
210        .build();
211    let content = Box::builder()
212        .orientation(Orientation::Vertical)
213        .spacing(12)
214        .margin_top(12)
215        .margin_bottom(12)
216        .margin_start(12)
217        .margin_end(12)
218        .build();
219    let scroll = ScrolledWindow::builder()
220        .hscrollbar_policy(gtk::PolicyType::Never)
221        .vscrollbar_policy(gtk::PolicyType::Automatic)
222        .vexpand(true)
223        .child(&content)
224        .build();
225    dialog.set_content(Some(&scroll));
226
227    let title = gtk::Label::builder()
228        .label(format!("Rules for {}", display_watch_path(folder_path)))
229        .halign(gtk::Align::Start)
230        .ellipsize(gtk::pango::EllipsizeMode::Middle)
231        .max_width_chars(28)
232        .build();
233    content.append(&title);
234
235    let current = rules_state.borrow().clone();
236
237    let list_box = gtk::ListBox::builder()
238        .selection_mode(gtk::SelectionMode::None)
239        .css_classes(vec![String::from("boxed-list")])
240        .build();
241
242    let ignore_hidden = adw::SwitchRow::builder()
243        .title("Ignore Hidden Files / Folders")
244        .subtitle("Skip paths that contain hidden components such as .cache or .thumbnails.")
245        .title_lines(1)
246        .subtitle_lines(3)
247        .active(current.ignore_hidden)
248        .build();
249
250    let sync_model = gtk::StringList::new(&[
251        "Full Sync",
252        "Only Upload from Folder",
253        "Only Download to Folder",
254    ]);
255    let sync_method = adw::ComboRow::builder()
256        .title("Sync Method")
257        .subtitle("Controls which direction this folder syncs.")
258        .title_lines(1)
259        .subtitle_lines(2)
260        .model(&sync_model)
261        .build();
262    sync_method.set_selected(match current.sync_method {
263        FolderSyncMethod::Full => 0,
264        FolderSyncMethod::UploadOnly => 1,
265        FolderSyncMethod::DownloadOnly => 2,
266    });
267
268    let startup_model = gtk::StringList::new(&["Full Scan", "Recent Only (7d)", "New Files Only"]);
269    let startup_scan = adw::ComboRow::builder()
270        .title("Startup Scan")
271        .subtitle("Controls how this folder is scanned when Mimick starts.")
272        .title_lines(1)
273        .subtitle_lines(2)
274        .model(&startup_model)
275        .build();
276    startup_scan.set_selected(
277        match current
278            .startup_catchup_mode
279            .clone()
280            .unwrap_or(fallback_catchup_mode)
281        {
282            StartupCatchupMode::Full => 0,
283            StartupCatchupMode::RecentOnly => 1,
284            StartupCatchupMode::NewFilesOnly => 2,
285        },
286    );
287
288    let delete_folder_to_album = adw::SwitchRow::builder()
289        .title("Mirror Folder Deletions to Album")
290        .subtitle("When a synced folder file is gone, move the matching Immich asset to trash.")
291        .title_lines(2)
292        .subtitle_lines(3)
293        .active(current.delete_folder_to_album)
294        .build();
295    let delete_album_to_folder = adw::SwitchRow::builder()
296        .title("Mirror Album Deletions to Folder")
297        .subtitle("Currently unavailable — waiting on an upstream Flatpak Trash portal fix. The setting stays off until then.")
298        .title_lines(2)
299        .subtitle_lines(4)
300        .active(false)
301        .sensitive(false)
302        .build();
303
304    let include_xmp = adw::SwitchRow::builder()
305        .title("Include XMP Sidecars")
306        .subtitle("Attach companion .xmp files alongside media during upload.")
307        .title_lines(1)
308        .subtitle_lines(2)
309        .active(current.include_xmp_sidecar.unwrap_or(true))
310        .build();
311
312    list_box.append(&sync_method);
313    list_box.append(&startup_scan);
314    list_box.append(&delete_folder_to_album);
315    list_box.append(&delete_album_to_folder);
316    list_box.append(&ignore_hidden);
317    list_box.append(&include_xmp);
318    content.append(&list_box);
319
320    let max_size_entry = Entry::builder()
321        .placeholder_text("Max size in MB (blank = no limit)")
322        .width_request(0)
323        .max_width_chars(16)
324        .text(
325            current
326                .max_file_size_mb
327                .map(|value| value.to_string())
328                .unwrap_or_default(),
329        )
330        .build();
331    content.append(&max_size_entry);
332
333    let extensions_entry = Entry::builder()
334        .placeholder_text("Extensions: jpg,png,mp4")
335        .width_request(0)
336        .max_width_chars(16)
337        .text(current.allowed_extensions.join(", "))
338        .build();
339    content.append(&extensions_entry);
340
341    let actions = Box::builder()
342        .orientation(Orientation::Horizontal)
343        .spacing(8)
344        .halign(gtk::Align::End)
345        .build();
346    let cancel_btn = Button::builder().label("Cancel").build();
347    let save_btn = Button::builder()
348        .label("Save")
349        .css_classes(vec!["suggested-action".to_string()])
350        .build();
351    actions.append(&cancel_btn);
352    actions.append(&save_btn);
353    content.append(&actions);
354
355    cancel_btn.connect_clicked(clone!(
356        #[weak]
357        dialog,
358        move |_| {
359            dialog.close();
360        }
361    ));
362
363    save_btn.connect_clicked(clone!(
364        #[weak]
365        dialog,
366        move |_| {
367            let max_file_size_mb = max_size_entry.text().trim().parse::<u64>().ok();
368            let allowed_extensions = extensions_entry
369                .text()
370                .split(',')
371                .map(|part| part.trim().trim_start_matches('.').to_ascii_lowercase())
372                .filter(|part| !part.is_empty())
373                .collect::<Vec<_>>();
374
375            // delete_album_to_folder UI is forced off / insensitive while
376            // album-to-folder mirroring is disabled.
377            let stored_delete_album_to_folder = rules_state.borrow().delete_album_to_folder;
378            *rules_state.borrow_mut() = FolderRules {
379                ignore_hidden: ignore_hidden.is_active(),
380                max_file_size_mb,
381                allowed_extensions,
382                sync_method: match sync_method.selected() {
383                    1 => FolderSyncMethod::UploadOnly,
384                    2 => FolderSyncMethod::DownloadOnly,
385                    _ => FolderSyncMethod::Full,
386                },
387                startup_catchup_mode: Some(match startup_scan.selected() {
388                    1 => StartupCatchupMode::RecentOnly,
389                    2 => StartupCatchupMode::NewFilesOnly,
390                    _ => StartupCatchupMode::Full,
391                }),
392                delete_folder_to_album: delete_folder_to_album.is_active(),
393                delete_album_to_folder: stored_delete_album_to_folder,
394                include_xmp_sidecar: Some(include_xmp.is_active()),
395            };
396            (on_settings_changed)();
397            dialog.close();
398        }
399    ));
400
401    dialog.present();
402}
403/// Construct and present a modal search and select window for linking a folder to an Immich album.
404pub(super) fn show_album_picker_dialog(
405    parent: &impl gtk::prelude::IsA<gtk::Window>,
406    albums_ref: Rc<RefCell<Vec<(String, String)>>>,
407    target_album_state: Rc<RefCell<String>>,
408    trigger_btn: Button,
409    on_settings_changed: Rc<dyn Fn()>,
410) {
411    let dialog = adw::Window::builder()
412        .transient_for(parent)
413        .modal(true)
414        .title("Select Album")
415        .default_width(400)
416        .default_height(500)
417        .width_request(360)
418        .build();
419
420    let header_bar = adw::HeaderBar::new();
421    let vbox = Box::builder().orientation(Orientation::Vertical).build();
422    dialog.set_content(Some(&vbox));
423    vbox.append(&header_bar);
424
425    let search_entry = gtk::SearchEntry::builder()
426        .halign(gtk::Align::Center)
427        .width_request(300)
428        .margin_top(8)
429        .margin_bottom(8)
430        .build();
431    vbox.append(&search_entry);
432
433    let list_box = gtk::ListBox::builder()
434        .selection_mode(gtk::SelectionMode::None)
435        .margin_start(12)
436        .margin_end(12)
437        .margin_bottom(12)
438        .build();
439    list_box.add_css_class("boxed-list");
440
441    let scrolled_window = gtk::ScrolledWindow::builder()
442        .hscrollbar_policy(gtk::PolicyType::Never)
443        .vscrollbar_policy(gtk::PolicyType::Automatic)
444        .vexpand(true)
445        .build();
446    scrolled_window.set_child(Some(&list_box));
447    vbox.append(&scrolled_window);
448
449    // Dynamic filtering capability
450    let albums_ref_cloned = albums_ref.clone();
451    let apply_filter = {
452        let list_box = list_box.clone();
453        let dialog = dialog.clone();
454        let target_album_state = target_album_state.clone();
455        let trigger_btn = trigger_btn.clone();
456
457        move |query: &str| {
458            // Clear existing
459            while let Some(child) = list_box.first_child() {
460                list_box.remove(&child);
461            }
462
463            let q = query.trim().to_lowercase();
464
465            // Row 1: Default Folder Name (only if it matches search)
466            if q.is_empty() || DEFAULT_ALBUM_LABEL.to_lowercase().contains(&q) {
467                let default_row = adw::ActionRow::builder()
468                    .title(DEFAULT_ALBUM_LABEL)
469                    .subtitle("Creates album dynamically per-folder")
470                    .activatable(true)
471                    .build();
472                let dialog_clone = dialog.clone();
473                let state_clone = target_album_state.clone();
474                let btn_clone = trigger_btn.clone();
475                let on_settings_changed_clone = on_settings_changed.clone();
476                default_row.connect_activated(move |_| {
477                    *state_clone.borrow_mut() = DEFAULT_ALBUM_LABEL.to_string();
478                    btn_clone.set_label(DEFAULT_ALBUM_LABEL);
479                    (on_settings_changed_clone)();
480                    dialog_clone.close();
481                });
482                list_box.append(&default_row);
483            }
484
485            // Row 2: Create Custom (if query is typed)
486            if !q.is_empty() {
487                let typed_raw = query.trim().to_string();
488                let create_row = adw::ActionRow::builder()
489                    .title(format!("Create new: \"{}\"", typed_raw))
490                    .activatable(true)
491                    .build();
492                let dialog_clone = dialog.clone();
493                let state_clone = target_album_state.clone();
494                let btn_clone = trigger_btn.clone();
495                let on_settings_changed_clone = on_settings_changed.clone();
496                create_row.connect_activated(move |_| {
497                    *state_clone.borrow_mut() = typed_raw.clone();
498                    btn_clone.set_label(&typed_raw);
499                    (on_settings_changed_clone)();
500                    dialog_clone.close();
501                });
502                list_box.append(&create_row);
503            }
504
505            // Row 3+: Remote Albums
506            for (name, _) in albums_ref_cloned.borrow().iter() {
507                if name == DEFAULT_ALBUM_LABEL {
508                    continue; // Skip the "Use default folder name" if we pushed it above
509                }
510                if q.is_empty() || name.to_lowercase().contains(&q) {
511                    let album_name = name.clone();
512                    let row = adw::ActionRow::builder()
513                        .title(&album_name)
514                        .activatable(true)
515                        .build();
516                    let dialog_clone = dialog.clone();
517                    let state_clone = target_album_state.clone();
518                    let btn_clone = trigger_btn.clone();
519                    let album_name_clone = album_name.clone();
520                    let on_settings_changed_clone = on_settings_changed.clone();
521                    row.connect_activated(move |_| {
522                        *state_clone.borrow_mut() = album_name_clone.clone();
523                        btn_clone.set_label(&album_name_clone);
524                        (on_settings_changed_clone)();
525                        dialog_clone.close();
526                    });
527                    list_box.append(&row);
528                }
529            }
530        }
531    };
532
533    // Initial populate
534    apply_filter("");
535
536    let apply_filter_rc = Rc::new(apply_filter);
537    search_entry.connect_search_changed(move |entry| {
538        apply_filter_rc(&entry.text());
539    });
540
541    dialog.present();
542}