mimick/settings_window/
watch_folders.rs1use 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
21pub(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
195fn 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 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}
403pub(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 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 while let Some(child) = list_box.first_child() {
460 list_box.remove(&child);
461 }
462
463 let q = query.trim().to_lowercase();
464
465 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 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 for (name, _) in albums_ref_cloned.borrow().iter() {
507 if name == DEFAULT_ALBUM_LABEL {
508 continue; }
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 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}