Skip to main content

mimick/settings_window/
mod.rs

1//! Implements the GTK4/Libadwaita settings window and status dashboard.
2//!
3//! Builds the tabbed preferences interface covering server configuration,
4//! watch-folder management, behaviour toggles, and a live health dashboard.
5//! Changes are validated and persisted to the JSON config file on save.
6
7use crate::autostart;
8use crate::config::{FolderRules, StartupCatchupMode, WatchPathEntry};
9use crate::diagnostics;
10use adw::prelude::*;
11use glib::clone;
12use gtk::prelude::*;
13use gtk::{Button, FileDialog, ListBox, ScrolledWindow};
14use libadwaita as adw;
15use std::cell::Cell;
16use std::cell::RefCell;
17use std::collections::HashMap;
18use std::path::Path;
19use std::rc::Rc;
20use std::sync::Arc;
21use std::time::Duration;
22
23use crate::app_context::AppContext;
24
25mod actions_ui;
26mod behavior;
27mod connectivity;
28mod library;
29mod queue_inspector;
30mod status;
31mod watch_folders;
32
33use queue_inspector::show_about_dialog;
34pub use queue_inspector::show_queue_inspector;
35use watch_folders::add_folder_row;
36
37/// Holds GTK widgets for a single watch-folder row in the settings list.
38struct FolderRowData {
39    /// Absolute local file path.
40    path: String,
41    /// Target Immich album name.
42    album_name: Rc<RefCell<String>>,
43    /// Custom path filtering rules.
44    rules: Rc<RefCell<FolderRules>>,
45    /// Libadwaita row widget representing this watched folder.
46    action_row: adw::ExpanderRow,
47    /// Base status subtitle for the row.
48    base_subtitle: String,
49}
50
51const DEFAULT_ALBUM_LABEL: &str = "Default (Folder Name)";
52
53/// Display a standard Libadwaita modal alert message dialog to the user.
54fn show_alert(parent: &impl gtk::prelude::IsA<gtk::Widget>, heading: &str, body: &str) {
55    let dialog = adw::AlertDialog::builder()
56        .heading(heading)
57        .body(body)
58        .build();
59    dialog.add_response("ok", "OK");
60    dialog.present(Some(parent));
61}
62
63/// Format a Unix timestamp representing the last sync time into a relative display string.
64fn format_sync_age(timestamp: Option<f64>) -> String {
65    let Some(timestamp) = timestamp else {
66        return "No successful sync yet".to_string();
67    };
68
69    let now = std::time::SystemTime::now()
70        .duration_since(std::time::UNIX_EPOCH)
71        .unwrap_or_default()
72        .as_secs_f64();
73    let elapsed = (now - timestamp).max(0.0);
74
75    if elapsed < 60.0 {
76        "Less than a minute ago".to_string()
77    } else if elapsed < 3600.0 {
78        format!("{} minute(s) ago", (elapsed / 60.0).floor() as u64)
79    } else if elapsed < 86_400.0 {
80        format!("{} hour(s) ago", (elapsed / 3600.0).floor() as u64)
81    } else {
82        format!("{} day(s) ago", (elapsed / 86_400.0).floor() as u64)
83    }
84}
85
86/// Construct and present the settings window as a top-level window.
87pub fn build_settings_window(app: &adw::Application, ctx: Arc<AppContext>) {
88    build_settings_window_with_parent(app, ctx, None);
89}
90
91/// Construct and present the settings window, optionally transient for a parent window.
92pub fn build_settings_window_with_parent(
93    app: &adw::Application,
94    ctx: Arc<AppContext>,
95    parent: Option<&adw::ApplicationWindow>,
96) {
97    let shared_state = ctx.state.clone();
98    let api_client = ctx.api_client.clone();
99    let queue_manager = ctx.queue_manager.clone();
100    let monitor_handle = ctx.monitor_handle.clone();
101    let live_watch_paths = ctx.live_watch_paths.clone();
102    let sync_now_tx = ctx.sync_now_tx.clone();
103    let thumbnail_cache = ctx.thumbnail_cache.clone();
104    let shared_config = ctx.config.clone();
105    // Use an application window with a Libadwaita header switcher and two pages.
106    let mut window_builder = adw::ApplicationWindow::builder()
107        .application(app)
108        .title("Mimick")
109        .name("mimick-settings-window")
110        .default_width(520)
111        .default_height(780);
112    if let Some(parent) = parent {
113        window_builder = window_builder
114            .transient_for(parent)
115            .modal(true)
116            .destroy_with_parent(true);
117    }
118    let window = window_builder.build();
119    window.set_size_request(360, 480);
120
121    let view_stack = adw::ViewStack::builder()
122        .vexpand(true)
123        .hexpand(true)
124        .build();
125    let page_switcher = adw::ViewSwitcher::builder().stack(&view_stack).build();
126    let header_bar = adw::HeaderBar::builder()
127        .title_widget(&page_switcher)
128        .build();
129    let about_header_btn = Button::builder()
130        .icon_name("help-about-symbolic")
131        .tooltip_text("About Mimick")
132        .build();
133    let window_clone_about = window.clone();
134    about_header_btn.connect_clicked(move |_| {
135        show_about_dialog(&window_clone_about);
136    });
137    header_bar.pack_start(&about_header_btn);
138
139    let toolbar_view = adw::ToolbarView::builder().build();
140    toolbar_view.add_top_bar(&header_bar);
141    toolbar_view.set_content(Some(&view_stack));
142    window.set_content(Some(&toolbar_view));
143
144    let status_scroll = ScrolledWindow::builder()
145        .hscrollbar_policy(gtk::PolicyType::Never)
146        .vexpand(true)
147        .hexpand(true)
148        .build();
149    let settings_scroll = ScrolledWindow::builder()
150        .hscrollbar_policy(gtk::PolicyType::Never)
151        .vexpand(true)
152        .hexpand(true)
153        .build();
154    view_stack.add_titled_with_icon(
155        &status_scroll,
156        Some("status"),
157        "Status",
158        "dialog-information-symbolic",
159    );
160    view_stack.add_titled_with_icon(
161        &settings_scroll,
162        Some("settings"),
163        "Settings",
164        "emblem-system-symbolic",
165    );
166
167    let app_clone = app.clone();
168    let config = ctx.config.read().clone();
169
170    let status_page = adw::PreferencesPage::builder()
171        .title("Status")
172        .icon_name("dialog-information-symbolic")
173        .build();
174    status_scroll.set_child(Some(&status_page));
175
176    let settings_page = adw::PreferencesPage::builder()
177        .title("Settings")
178        .icon_name("emblem-system-symbolic")
179        .build();
180    settings_scroll.set_child(Some(&settings_page));
181
182    let is_unconfigured = config.get_api_key().unwrap_or_default().is_empty();
183    if is_unconfigured {
184        let welcome_group = adw::PreferencesGroup::builder()
185            .title("Welcome to Mimick!")
186            .description("Start by adding your API key, testing the connection, and choosing at least one folder. The key needs Asset (read, view, upload, update, download, delete), Album (read, create, update), and albumAsset (create, delete) permissions.")
187            .build();
188
189        let help_row = adw::ActionRow::builder()
190            .title("How to get an API Key")
191            .subtitle("Base sync: user.read, asset.upload/update, album.read/create, albumAsset.create. See docs for Library/deletion scopes.")
192            .activatable(true)
193            .build();
194
195        help_row.connect_activated(|_| {
196            let uri = "https://immich.app/docs/features/command-line-interface/#api-key";
197            if let Err(e) =
198                gtk::gio::AppInfo::launch_default_for_uri(uri, None::<&gtk::gio::AppLaunchContext>)
199            {
200                log::error!("Failed to open browser: {}", e);
201            }
202        });
203
204        welcome_group.add(&help_row);
205        settings_page.add(&welcome_group);
206    }
207
208    let status::StatusWidgets {
209        status_row,
210        progress_bar,
211        route_row,
212        folders_row,
213        queue_health_row,
214        last_sync_row,
215        error_row,
216    } = status::build_status_group(&status_page);
217
218    let connectivity::ConnectivityWidgets {
219        internal_switch,
220        external_switch,
221        internal_entry,
222        external_entry,
223        api_key_entry,
224        test_btn,
225        save_btn,
226    } = connectivity::build_connectivity_group(&settings_page, &window);
227
228    // Clone before moving into test_btn closure so api_client is still available below
229    let api_client_for_test = api_client.clone();
230    test_btn.connect_clicked(clone!(
231        #[weak]
232        internal_switch,
233        #[weak]
234        external_switch,
235        #[weak]
236        internal_entry,
237        #[weak]
238        external_entry,
239        #[weak]
240        api_key_entry,
241        #[weak]
242        window,
243        #[weak]
244        test_btn,
245        move |btn| {
246            btn.set_sensitive(false);
247
248            // Collect only primitive/String values – no GTK types cross threads
249            let internal = if internal_switch.is_active() {
250                internal_entry.text().to_string()
251            } else {
252                String::new()
253            };
254            let external = if external_switch.is_active() {
255                external_entry.text().to_string()
256            } else {
257                String::new()
258            };
259            let _api_key = api_key_entry.text().to_string();
260
261            let (tx, mut rx) = tokio::sync::oneshot::channel::<(bool, bool)>();
262
263            // Use the application-wide API client — do NOT create ImmichApiClient::new() here.
264            // Creating a fresh reqwest client per click allocates a new connection pool
265            // that lingers for 30s even after the test completes.
266            let ping_client = api_client_for_test.clone();
267            let internal2 = internal.clone();
268            let external2 = external.clone();
269            tokio::spawn(async move {
270                let int_ok = if !internal2.is_empty() {
271                    ping_client.ping_url(&internal2).await
272                } else {
273                    false
274                };
275                let ext_ok = if !external2.is_empty() {
276                    ping_client.ping_url(&external2).await
277                } else {
278                    false
279                };
280                let _ = tx.send((int_ok, ext_ok));
281            });
282
283            // Poll the oneshot receiver from the GTK main loop
284            glib::timeout_add_local(
285                Duration::from_millis(50),
286                clone!(
287                    #[weak]
288                    window,
289                    #[weak]
290                    test_btn,
291                    #[upgrade_or]
292                    glib::ControlFlow::Break,
293                    move || {
294                        match rx.try_recv() {
295                            Ok((int_ok, ext_ok)) => {
296                                test_btn.set_sensitive(true);
297
298                                let int_label = if int_ok { "OK" } else { "FAILED" };
299                                let ext_label = if ext_ok { "OK" } else { "FAILED" };
300                                let mut report =
301                                    format!("Internal: {}\nExternal: {}", int_label, ext_label);
302                                let heading = if int_ok || ext_ok {
303                                    if int_ok {
304                                        report.push_str("\n\nActive Mode: LAN");
305                                    } else {
306                                        report.push_str("\n\nActive Mode: WAN");
307                                    }
308                                    "Connection Successful"
309                                } else {
310                                    report = "Could not connect to Immich at either address."
311                                        .to_string();
312                                    "Connection Failed"
313                                };
314
315                                show_alert(&window, heading, &report);
316
317                                glib::ControlFlow::Break
318                            }
319                            Err(tokio::sync::oneshot::error::TryRecvError::Empty) => {
320                                // Still waiting
321                                glib::ControlFlow::Continue
322                            }
323                            Err(_) => glib::ControlFlow::Break, // channel dropped
324                        }
325                    }
326                ),
327            );
328        }
329    ));
330
331    let behavior::BehaviorWidgets {
332        startup_row,
333        background_sync_row,
334        metered_row,
335        battery_row,
336        notifications_row,
337        library_view_row,
338        catchup_row,
339        concurrency_row,
340        xmp_sidecar_row,
341        quiet_hours_row,
342        quiet_start_row,
343        quiet_end_row,
344    } = behavior::build_behavior_group(&settings_page);
345
346    // Show the hour spinners only when quiet hours are enabled
347    quiet_hours_row.connect_active_notify(clone!(
348        #[weak]
349        quiet_start_row,
350        #[weak]
351        quiet_end_row,
352        move |row| {
353            quiet_start_row.set_sensitive(row.is_active());
354            quiet_end_row.set_sensitive(row.is_active());
355        }
356    ));
357
358    let library::LibraryWidgets {
359        library_group,
360        preview_full_row,
361        grid_quality_row,
362        raw_full_decode_row,
363        raw_cache_row,
364        disk_cache_row,
365        download_folder_row,
366        download_change_btn,
367        download_clear_btn,
368        border_width_row,
369        border_color_btn,
370    } = library::build_library_group(&settings_page);
371
372    let ctx_for_library = ctx.clone();
373    library_view_row.connect_active_notify(glib::clone!(
374        #[weak]
375        library_group,
376        move |row| {
377            let active = row.is_active();
378            library_group.set_visible(active);
379            let mut cfg = ctx_for_library.config.write();
380            if cfg.data.library_view_enabled != active {
381                cfg.data.library_view_enabled = active;
382                cfg.save();
383            }
384        }
385    ));
386
387    let ctx_for_preview = ctx.clone();
388    preview_full_row.connect_active_notify(move |row| {
389        let active = row.is_active();
390        let mut cfg = ctx_for_preview.config.write();
391        if cfg.data.library_preview_full_resolution != active {
392            cfg.data.library_preview_full_resolution = active;
393            cfg.save();
394        }
395    });
396
397    let initial_quality_idx = match ctx.config.read().data.library_grid_quality.as_str() {
398        "thumbnail" => 1,
399        "preview" => 2,
400        "fullsize" => 3,
401        _ => 0,
402    };
403    grid_quality_row.set_selected(initial_quality_idx);
404    let ctx_for_quality = ctx.clone();
405    grid_quality_row.connect_selected_notify(move |row| {
406        let value = match row.selected() {
407            1 => "thumbnail",
408            2 => "preview",
409            3 => "fullsize",
410            _ => "auto",
411        };
412        let mut cfg = ctx_for_quality.config.write();
413        if cfg.data.library_grid_quality != value {
414            cfg.data.library_grid_quality = value.to_string();
415            cfg.save();
416        }
417    });
418
419    raw_cache_row.set_sensitive(raw_full_decode_row.is_active());
420
421    let cache_row_ref = raw_cache_row.clone();
422    let ctx_for_raw_decode = ctx.clone();
423    raw_full_decode_row.connect_active_notify(move |row| {
424        let active = row.is_active();
425        cache_row_ref.set_sensitive(active);
426        crate::library::set_raw_full_decode(active);
427        let mut cfg = ctx_for_raw_decode.config.write();
428        if cfg.data.raw_full_decode != active {
429            cfg.data.raw_full_decode = active;
430            cfg.save();
431        }
432    });
433
434    let ctx_for_raw_cache = ctx.clone();
435    raw_cache_row.connect_active_notify(move |row| {
436        let active = row.is_active();
437        crate::library::set_raw_cache_enabled(active);
438        let mut cfg = ctx_for_raw_cache.config.write();
439        if cfg.data.raw_decode_cache_enabled != active {
440            cfg.data.raw_decode_cache_enabled = active;
441            cfg.save();
442        }
443    });
444
445    let pending_disk_cache_save: Rc<Cell<Option<glib::SourceId>>> = Rc::new(Cell::new(None));
446    let ctx_for_disk_cache = ctx.clone();
447    disk_cache_row.connect_value_notify(move |row| {
448        let new_value = row.value() as u32;
449        if let Some(id) = pending_disk_cache_save.take() {
450            id.remove();
451        }
452        let ctx_for_save = ctx_for_disk_cache.clone();
453        let pending = pending_disk_cache_save.clone();
454        let id = glib::timeout_add_local_once(Duration::from_millis(400), move || {
455            pending.set(None);
456            let mut cfg = ctx_for_save.config.write();
457            if cfg.data.cache_disk_cap_mb != new_value {
458                cfg.data.cache_disk_cap_mb = new_value;
459                cfg.save();
460            }
461        });
462        pending_disk_cache_save.set(Some(id));
463    });
464
465    // --- Download folder row wiring ---
466    if let Some(path) = ctx.config.read().data.download_target_path.as_deref() {
467        download_folder_row.set_subtitle(path);
468        download_clear_btn.set_visible(true);
469    }
470
471    let ctx_for_dl_change = ctx.clone();
472    let dl_row_for_change = download_folder_row.clone();
473    let dl_clear_for_change = download_clear_btn.clone();
474    download_change_btn.connect_clicked(clone!(
475        #[weak]
476        window,
477        move |_| {
478            let ctx = ctx_for_dl_change.clone();
479            let row = dl_row_for_change.clone();
480            let clear = dl_clear_for_change.clone();
481            let dialog = gtk::FileDialog::builder()
482                .title("Choose Download Folder")
483                .build();
484            dialog.select_folder(Some(&window), gtk::gio::Cancellable::NONE, move |res| {
485                if let Some(path) = res.ok().and_then(|f| f.path()) {
486                    let path_str = path.to_string_lossy().to_string();
487                    row.set_subtitle(&path_str);
488                    clear.set_visible(true);
489                    let mut cfg = ctx.config.write();
490                    cfg.data.download_target_path = Some(path_str);
491                    cfg.save();
492                }
493            });
494        }
495    ));
496
497    let ctx_for_dl_clear = ctx.clone();
498    download_clear_btn.connect_clicked(clone!(
499        #[weak]
500        download_folder_row,
501        move |btn| {
502            download_folder_row.set_subtitle("Not set");
503            btn.set_visible(false);
504            let mut cfg = ctx_for_dl_clear.config.write();
505            cfg.data.download_target_path = None;
506            cfg.save();
507        }
508    ));
509
510    // --- WATCH FOLDERS GROUP ---
511    let folders_group = adw::PreferencesGroup::builder()
512        .title("Watch Folders")
513        .description("Pick folders to sync.")
514        .build();
515    settings_page.add(&folders_group);
516
517    let startup_state = Rc::new(RefCell::new(config.data.run_on_startup));
518    let background_sync_state = Rc::new(RefCell::new(config.data.background_sync_enabled));
519    let apply_in_flight = Rc::new(Cell::new(false));
520    let tracked_rows = Rc::new(RefCell::new(Vec::<FolderRowData>::new()));
521    let albums: Rc<RefCell<Vec<(String, String)>>> = Rc::new(RefCell::new(Vec::new()));
522
523    let apply_settings = Rc::new(clone!(
524        #[weak]
525        window,
526        #[weak]
527        startup_row,
528        #[weak]
529        internal_switch,
530        #[weak]
531        external_switch,
532        #[weak]
533        internal_entry,
534        #[weak]
535        external_entry,
536        #[weak]
537        api_key_entry,
538        #[weak]
539        metered_row,
540        #[weak]
541        battery_row,
542        #[weak]
543        notifications_row,
544        #[weak]
545        library_view_row,
546        #[weak]
547        preview_full_row,
548        #[weak]
549        raw_full_decode_row,
550        #[weak]
551        raw_cache_row,
552        #[weak]
553        disk_cache_row,
554        #[weak]
555        concurrency_row,
556        #[weak]
557        quiet_hours_row,
558        #[weak]
559        quiet_start_row,
560        #[weak]
561        quiet_end_row,
562        #[weak]
563        catchup_row,
564        #[weak]
565        background_sync_row,
566        #[weak]
567        xmp_sidecar_row,
568        #[weak]
569        border_width_row,
570        #[weak]
571        border_color_btn,
572        #[strong]
573        tracked_rows,
574        #[strong]
575        albums,
576        #[strong]
577        shared_state,
578        #[strong]
579        api_client,
580        #[strong]
581        queue_manager,
582        #[strong]
583        monitor_handle,
584        #[strong]
585        live_watch_paths,
586        #[strong]
587        sync_now_tx,
588        #[strong]
589        startup_state,
590        #[strong]
591        background_sync_state,
592        #[strong]
593        apply_in_flight,
594        #[strong]
595        shared_config,
596        move |include_connectivity: bool, show_success_ack: bool| {
597            if apply_in_flight.get() {
598                return;
599            }
600            apply_in_flight.set(true);
601
602            let (
603                mut internal_url_enabled,
604                mut external_url_enabled,
605                mut internal_url,
606                mut external_url,
607                mut api_key,
608            ) = {
609                let existing = shared_config.read();
610                (
611                    existing.data.internal_url_enabled,
612                    existing.data.external_url_enabled,
613                    existing.data.internal_url.clone(),
614                    existing.data.external_url.clone(),
615                    existing.get_api_key().unwrap_or_default(),
616                )
617            };
618            if include_connectivity {
619                internal_url_enabled = internal_switch.is_active();
620                external_url_enabled = external_switch.is_active();
621                internal_url = internal_entry.text().to_string();
622                external_url = external_entry.text().to_string();
623                api_key = api_key_entry.text().to_string();
624
625                // Reject non-HTTP(S) URL schemes before persisting.
626                if internal_url_enabled
627                    && !internal_url.trim().is_empty()
628                    && let Err(err) = crate::sanitize::validate_http_url(&internal_url)
629                {
630                    show_alert(&window, "Invalid Internal URL", &err);
631                    apply_in_flight.set(false);
632                    return;
633                }
634                if external_url_enabled
635                    && !external_url.trim().is_empty()
636                    && let Err(err) = crate::sanitize::validate_http_url(&external_url)
637                {
638                    show_alert(&window, "Invalid External URL", &err);
639                    apply_in_flight.set(false);
640                    return;
641                }
642            }
643            let run_on_startup = startup_row.is_active();
644            let pause_on_metered_network = metered_row.is_active();
645            let pause_on_battery_power = battery_row.is_active();
646            let notifications_enabled = notifications_row.is_active();
647            let library_view_enabled = library_view_row.is_active();
648            let library_preview_full_resolution = preview_full_row.is_active();
649            let raw_decode_cache_enabled = raw_cache_row.is_active();
650            let raw_full_decode = raw_full_decode_row.is_active();
651            let cache_disk_cap_mb = disk_cache_row.value() as u32;
652            let upload_concurrency = concurrency_row.value() as u8;
653            let quiet_hours_enabled = quiet_hours_row.is_active();
654            let quiet_hours_start = quiet_hours_enabled.then(|| quiet_start_row.value() as u8);
655            let quiet_hours_end = quiet_hours_enabled.then(|| quiet_end_row.value() as u8);
656            let background_sync_enabled = background_sync_row.is_active();
657            let upload_xmp_sidecars = xmp_sidecar_row.is_active();
658            let grid_border_width = border_width_row.value() as f32;
659            let grid_border_color = border_color_btn.rgba().to_string();
660            let catchup_mode = match catchup_row.selected() {
661                1 => StartupCatchupMode::RecentOnly,
662                2 => StartupCatchupMode::NewFilesOnly,
663                _ => StartupCatchupMode::Full,
664            };
665
666            let mut watch_paths = Vec::new();
667            let albums_map: HashMap<String, String> = albums.borrow().iter().cloned().collect();
668            for row_data in tracked_rows.borrow().iter() {
669                let folder = row_data.path.clone();
670                let rules = row_data.rules.borrow().clone();
671                let has_rules = rules != FolderRules::default();
672                let album_name = row_data.album_name.borrow().clone();
673
674                let is_default = album_name.is_empty() || album_name == DEFAULT_ALBUM_LABEL;
675                let resolved_album_name = if is_default {
676                    Path::new(&folder)
677                        .file_name()
678                        .and_then(|n| n.to_str())
679                        .map(|s| s.to_string())
680                } else {
681                    Some(album_name)
682                };
683
684                if is_default && !has_rules && resolved_album_name.is_none() {
685                    watch_paths.push(WatchPathEntry::Simple(folder));
686                } else {
687                    let album_id = resolved_album_name
688                        .as_ref()
689                        .and_then(|n| albums_map.get(n).cloned());
690                    watch_paths.push(WatchPathEntry::WithConfig {
691                        path: folder,
692                        album_id,
693                        album_name: resolved_album_name,
694                        rules,
695                    });
696                }
697            }
698
699            let runtime_internal_url = if internal_url_enabled {
700                internal_url.clone()
701            } else {
702                String::new()
703            };
704            let runtime_external_url = if external_url_enabled {
705                external_url.clone()
706            } else {
707                String::new()
708            };
709
710            let previous_startup = *startup_state.borrow();
711            let previous_background_sync = *background_sync_state.borrow();
712
713            glib::MainContext::default().spawn_local(clone!(
714                #[weak]
715                window,
716                #[weak]
717                startup_row,
718                #[strong]
719                shared_state,
720                #[strong]
721                api_client,
722                #[strong]
723                queue_manager,
724                #[strong]
725                monitor_handle,
726                #[strong]
727                albums,
728                #[strong]
729                live_watch_paths,
730                #[strong]
731                sync_now_tx,
732                #[strong]
733                startup_state,
734                #[strong]
735                background_sync_state,
736                #[strong]
737                apply_in_flight,
738                #[strong]
739                shared_config,
740                async move {
741                    if run_on_startup != previous_startup {
742                        match autostart::apply(&window, run_on_startup).await {
743                            Ok(granted) if granted == run_on_startup => {}
744                            Ok(_) => {
745                                startup_row.set_active(previous_startup);
746                                apply_in_flight.set(false);
747
748                                show_alert(
749                                    &window,
750                                    "Startup Permission Needed",
751                                    "Mimick was not allowed to start automatically at login.",
752                                );
753                                return;
754                            }
755                            Err(err) => {
756                                startup_row.set_active(previous_startup);
757                                apply_in_flight.set(false);
758
759                                show_alert(&window, "Could Not Update Startup Setting", &err);
760                                return;
761                            }
762                        }
763                    }
764
765                    {
766                        let mut new_config = shared_config.write();
767                        new_config.data.internal_url_enabled = internal_url_enabled;
768                        new_config.data.external_url_enabled = external_url_enabled;
769                        new_config.data.internal_url = internal_url;
770                        new_config.data.external_url = external_url;
771                        new_config.data.watch_paths = watch_paths.clone();
772                        new_config.data.run_on_startup = run_on_startup;
773                        new_config.data.background_sync_enabled = background_sync_enabled;
774                        new_config.data.pause_on_metered_network = pause_on_metered_network;
775                        new_config.data.pause_on_battery_power = pause_on_battery_power;
776                        new_config.data.notifications_enabled = notifications_enabled;
777                        new_config.data.library_view_enabled = library_view_enabled;
778                        new_config.data.library_preview_full_resolution =
779                            library_preview_full_resolution;
780                        new_config.data.raw_decode_cache_enabled = raw_decode_cache_enabled;
781                        crate::library::set_raw_cache_enabled(raw_decode_cache_enabled);
782                        new_config.data.raw_full_decode = raw_full_decode;
783                        crate::library::set_raw_full_decode(raw_full_decode);
784                        new_config.data.cache_disk_cap_mb = cache_disk_cap_mb;
785                        new_config.data.startup_catchup_mode = catchup_mode;
786                        new_config.data.upload_concurrency = upload_concurrency;
787                        new_config.data.quiet_hours_start = quiet_hours_start;
788                        new_config.data.quiet_hours_end = quiet_hours_end;
789                        new_config.data.upload_xmp_sidecars = upload_xmp_sidecars;
790                        new_config.data.grid_border_width = grid_border_width;
791                        new_config.data.grid_border_color = grid_border_color.clone();
792
793                        if include_connectivity
794                            && !api_key.is_empty()
795                            && let Err(detail) = new_config.set_api_key(&api_key)
796                        {
797                            apply_in_flight.set(false);
798
799                            show_alert(&window, "Could Not Save API Key", &detail);
800                            return;
801                        }
802
803                        if !new_config.save() {
804                            apply_in_flight.set(false);
805
806                            show_alert(
807                                &window,
808                                "Could Not Save Settings",
809                                "Mimick could not write the updated configuration to disk.",
810                            );
811                            return;
812                        }
813                    }
814
815                    *startup_state.borrow_mut() = run_on_startup;
816                    *background_sync_state.borrow_mut() = background_sync_enabled;
817
818                    api_client
819                        .update_settings(
820                            runtime_internal_url.clone(),
821                            runtime_external_url.clone(),
822                            api_key.clone(),
823                        )
824                        .await;
825
826                    if include_connectivity && !api_key.is_empty() {
827                        match api_client.get_all_albums().await {
828                            Ok(fetched) => {
829                                *albums.borrow_mut() = fetched;
830                            }
831                            Err(err) => {
832                                log::warn!("Could not fetch albums after saving settings: {}", err);
833                            }
834                        }
835                    }
836
837                    queue_manager.set_worker_limit(upload_concurrency);
838                    queue_manager.update_environment_policy(
839                        crate::queue_manager::EnvironmentPolicy {
840                            pause_on_metered_network,
841                            pause_on_battery_power,
842                            quiet_hours_start,
843                            quiet_hours_end,
844                        },
845                    );
846
847                    crate::notifications::set_enabled(notifications_enabled);
848
849                    if previous_background_sync != background_sync_enabled {
850                        let mut state = shared_state.lock();
851                        if !background_sync_enabled && state.status != "uploading" && !state.paused
852                        {
853                            state.status = "idle".to_string();
854                            state.pause_reason = None;
855                        }
856                    }
857
858                    let monitor_paths = if background_sync_enabled {
859                        watch_paths.clone()
860                    } else {
861                        Vec::new()
862                    };
863                    monitor_handle.replace_watch_paths(monitor_paths, background_sync_enabled);
864
865                    *live_watch_paths.lock() = watch_paths.clone();
866
867                    if background_sync_enabled
868                        && previous_background_sync != background_sync_enabled
869                    {
870                        let _ = sync_now_tx.send(());
871                    }
872
873                    {
874                        let mut state = shared_state.lock();
875                        state.watched_folder_count = watch_paths.len();
876                        let current_paths = watch_paths
877                            .iter()
878                            .map(|entry| entry.path().to_string())
879                            .collect::<std::collections::HashSet<_>>();
880                        state
881                            .folder_statuses
882                            .retain(|path, _| current_paths.contains(path));
883                    }
884
885                    apply_in_flight.set(false);
886                    if show_success_ack {
887                        show_alert(
888                            &window,
889                            "Settings Saved",
890                            "Mimick saved the updated settings successfully.",
891                        );
892                    }
893                }
894            ));
895        }
896    ));
897
898    let auto_apply_settings: Rc<dyn Fn()> = Rc::new(clone!(
899        #[strong]
900        apply_settings,
901        move || {
902            (apply_settings)(false, false);
903        }
904    ));
905
906    // Reuse the application-wide API client — do NOT create a new one here.
907    // Creating a new reqwest Client per window open allocates a new connection pool
908    // that takes ~30s to self-clean, causing RAM to grow with each open/close cycle.
909    let albums_ref = albums.clone();
910
911    // Downgrade the window to a weak ref BEFORE the spawn.
912    // After the async await, we upgrade it — if it's None the window was closed
913    // while the API call was in-flight. We bail immediately, releasing all strong
914    // refs to FolderRowData (and their contained GTK widgets) so they can be freed.
915    // Without this, rapid open/close cycles would accumulate orphaned widget sets.
916    let weak_win = window.downgrade();
917    let client = api_client.clone();
918
919    glib::MainContext::default().spawn_local(async move {
920        let fetched = client.get_all_albums().await.unwrap_or_default();
921
922        // Window may have been closed while we awaited the network response.
923        // Bail out early — drops tracked_rows_async and albums_ref immediately.
924        if weak_win.upgrade().is_none() {
925            log::debug!("Settings window closed during album fetch — discarding result.");
926            return;
927        }
928
929        *albums_ref.borrow_mut() = fetched.clone();
930
931        // Album picker dialog fetches directly from albums_ref when opened.
932        // We don't need to push updates to existing rows anymore.
933    });
934
935    // List FIRST (matching Python layout), then Add button below
936    let folders_list = ListBox::builder()
937        .margin_top(12)
938        .selection_mode(gtk::SelectionMode::None)
939        .css_classes(vec!["boxed-list".to_string()])
940        .build();
941    folders_group.add(&folders_list);
942
943    let add_folder_btn = Button::builder().label("Add Folder").margin_top(12).build();
944    folders_group.add(&add_folder_btn);
945
946    let folder_default_catchup = config.data.startup_catchup_mode.clone();
947
948    // Add existing paths to listbox with album dropdown
949    for entry in &config.data.watch_paths {
950        add_folder_row(
951            &folders_list,
952            entry,
953            folder_default_catchup.clone(),
954            albums.clone(),
955            &tracked_rows,
956            auto_apply_settings.clone(),
957        );
958    }
959
960    let folders_list_clone = folders_list.clone();
961    let window_clone = window.clone();
962    let tracked_rows_clone = tracked_rows.clone();
963    let albums_clone = albums.clone();
964    let apply_settings_for_add = auto_apply_settings.clone();
965    let folder_default_catchup_for_add = folder_default_catchup.clone();
966
967    add_folder_btn.connect_clicked(move |_| {
968        let dialog = FileDialog::builder().title("Select Watch Folder").build();
969        let list_clone = folders_list_clone.clone();
970        let tracked_clone = tracked_rows_clone.clone();
971        let albums_ref = albums_clone.clone();
972        let apply_settings_for_add = apply_settings_for_add.clone();
973        let folder_default_catchup_for_add = folder_default_catchup_for_add.clone();
974
975        dialog.select_folder(
976            Some(&window_clone),
977            gtk::gio::Cancellable::NONE,
978            move |res| {
979                if let Ok(file) = res
980                    && let Some(path) = file.path()
981                {
982                    let path_str = path.to_string_lossy().to_string();
983                    if tracked_clone.borrow().iter().any(|r| r.path == path_str) {
984                        return;
985                    }
986                    add_folder_row(
987                        &list_clone,
988                        &WatchPathEntry::Simple(path_str),
989                        folder_default_catchup_for_add.clone(),
990                        albums_ref.clone(),
991                        &tracked_clone,
992                        apply_settings_for_add.clone(),
993                    );
994                    (apply_settings_for_add)();
995                }
996            },
997        );
998    });
999
1000    let actions_ui::ActionsWidgets {
1001        sync_now_btn,
1002        pause_btn,
1003        queue_btn,
1004        export_btn,
1005        clear_cache_btn,
1006        quit_btn,
1007    } = actions_ui::build_actions_group(&status_page, &settings_page);
1008
1009    pause_btn.set_label(if queue_manager.is_paused() {
1010        "Resume"
1011    } else {
1012        "Pause"
1013    });
1014
1015    let qm_for_inspector = queue_manager.clone();
1016    queue_btn.connect_clicked(clone!(
1017        #[weak]
1018        window,
1019        move |_| {
1020            show_queue_inspector(&window, qm_for_inspector.clone());
1021        }
1022    ));
1023
1024    let qm_for_pause = queue_manager.clone();
1025    pause_btn.connect_clicked(clone!(
1026        #[weak]
1027        pause_btn,
1028        move |_| {
1029            let paused = !qm_for_pause.is_paused();
1030            qm_for_pause.set_paused(paused, paused.then(|| "Paused by user".to_string()));
1031            pause_btn.set_label(if paused { "Resume" } else { "Pause" });
1032        }
1033    ));
1034
1035    sync_now_btn.connect_clicked(move |_| {
1036        let _ = sync_now_tx.send(());
1037    });
1038
1039    export_btn.connect_clicked(clone!(
1040        #[weak]
1041        window,
1042        #[strong]
1043        shared_state,
1044        #[strong]
1045        shared_config,
1046        move |_| {
1047            let dialog = FileDialog::builder()
1048                .title("Choose Diagnostics Export Folder")
1049                .build();
1050            let state = shared_state.clone();
1051            let config_ref = shared_config.clone();
1052            dialog.select_folder(
1053                Some(&window),
1054                gtk::gio::Cancellable::NONE,
1055                clone!(
1056                    #[weak]
1057                    window,
1058                    move |res| {
1059                        if let Ok(folder) = res
1060                            && let Some(path) = folder.path()
1061                        {
1062                            let state_snapshot = state.lock().clone();
1063                            let config_snapshot = config_ref.read().clone();
1064                            glib::MainContext::default().spawn_local(clone!(
1065                                #[weak]
1066                                window,
1067                                async move {
1068                                    let export_result = tokio::task::spawn_blocking(move || {
1069                                        diagnostics::export_bundle(
1070                                            &path,
1071                                            &state_snapshot,
1072                                            &config_snapshot,
1073                                        )
1074                                    })
1075                                    .await;
1076
1077                                    let (heading, body) = match export_result {
1078                                        Ok(Ok(bundle_dir)) => (
1079                                            "Diagnostics Exported",
1080                                            format!(
1081                                                "Saved diagnostics bundle to {}",
1082                                                crate::watch_path_display::display_watch_path_inline(
1083                                                    &bundle_dir.parent().unwrap_or(&bundle_dir).to_string_lossy()
1084                                                )
1085                                            ),
1086                                        ),
1087                                        Ok(Err(err)) => (
1088                                            "Diagnostics Export Failed",
1089                                            format!("Could not write diagnostics bundle: {}", err),
1090                                        ),
1091                                        Err(err) => (
1092                                            "Diagnostics Export Failed",
1093                                            format!("Diagnostics task could not complete: {}", err),
1094                                        ),
1095                                    };
1096
1097                                    show_alert(&window, heading, &body);
1098                                }
1099                            ));
1100                        }
1101                    }
1102                ),
1103            );
1104        }
1105    ));
1106
1107    clear_cache_btn.connect_clicked(clone!(
1108        #[weak]
1109        window,
1110        move |_| {
1111            // Drop the in-memory thumbnail textures immediately, then wipe
1112            // every on-disk cache subdirectory off the UI thread.
1113            let _ = thumbnail_cache.clear();
1114            let window_for_done = window.clone();
1115            glib::MainContext::default().spawn_local(async move {
1116                let result = tokio::task::spawn_blocking(crate::cache_manager::clear_all_blocking)
1117                    .await
1118                    .map_err(|err| err.to_string())
1119                    .and_then(|inner| inner);
1120                let (heading, body) = match result {
1121                    Ok(()) => (
1122                        "Cache Cleared",
1123                        "Removed thumbnails, decoded RAW previews, EXIF, video, and preview caches."
1124                            .to_string(),
1125                    ),
1126                    Err(err) => ("Could Not Clear Cache", err),
1127                };
1128                show_alert(&window_for_done, heading, &body);
1129            });
1130        }
1131    ));
1132
1133    quit_btn.connect_clicked(clone!(
1134        #[strong]
1135        app_clone,
1136        move |_| {
1137            app_clone.quit();
1138        }
1139    ));
1140
1141    save_btn.connect_clicked(clone!(
1142        #[strong]
1143        apply_settings,
1144        move |_| {
1145            (apply_settings)(true, true);
1146        }
1147    ));
1148
1149    // Populate from config
1150    internal_switch.set_active(config.data.internal_url_enabled);
1151    external_switch.set_active(config.data.external_url_enabled);
1152    internal_entry.set_text(&config.data.internal_url);
1153    external_entry.set_text(&config.data.external_url);
1154    internal_entry.set_sensitive(config.data.internal_url_enabled);
1155    external_entry.set_sensitive(config.data.external_url_enabled);
1156    startup_row.set_active(config.data.run_on_startup);
1157    metered_row.set_active(config.data.pause_on_metered_network);
1158    battery_row.set_active(config.data.pause_on_battery_power);
1159    background_sync_row.set_active(config.data.background_sync_enabled);
1160    notifications_row.set_active(config.data.notifications_enabled);
1161    library_view_row.set_active(config.data.library_view_enabled);
1162    preview_full_row.set_active(config.data.library_preview_full_resolution);
1163    raw_cache_row.set_active(config.data.raw_decode_cache_enabled);
1164    raw_full_decode_row.set_active(config.data.raw_full_decode);
1165    raw_cache_row.set_sensitive(config.data.raw_full_decode);
1166    if config.data.cache_disk_cap_mb > 0 {
1167        disk_cache_row.set_value(config.data.cache_disk_cap_mb as f64);
1168    }
1169    concurrency_row.set_value(config.data.upload_concurrency as f64);
1170    xmp_sidecar_row.set_active(config.data.upload_xmp_sidecars);
1171    border_width_row.set_value(config.data.grid_border_width as f64);
1172    if let Ok(color) = config.data.grid_border_color.parse::<gdk4::RGBA>() {
1173        border_color_btn.set_rgba(&color);
1174    }
1175    let qh_enabled = config.data.quiet_hours_start.is_some();
1176    quiet_hours_row.set_active(qh_enabled);
1177    quiet_start_row.set_value(config.data.quiet_hours_start.unwrap_or(22) as f64);
1178    quiet_end_row.set_value(config.data.quiet_hours_end.unwrap_or(7) as f64);
1179    quiet_start_row.set_sensitive(qh_enabled);
1180    quiet_end_row.set_sensitive(qh_enabled);
1181    catchup_row.set_selected(match config.data.startup_catchup_mode {
1182        StartupCatchupMode::Full => 0,
1183        StartupCatchupMode::RecentOnly => 1,
1184        StartupCatchupMode::NewFilesOnly => 2,
1185    });
1186
1187    if let Some(key) = config.get_api_key() {
1188        api_key_entry.set_text(&key);
1189    }
1190
1191    // Toggle validation — at least one URL must always be enabled
1192    internal_switch.connect_active_notify(clone!(
1193        #[weak]
1194        external_switch,
1195        #[weak]
1196        internal_entry,
1197        #[weak]
1198        window,
1199        move |switch| {
1200            if !switch.is_active() && !external_switch.is_active() {
1201                switch.set_active(true);
1202                show_alert(
1203                    &window,
1204                    "Invalid Selection",
1205                    "At least one URL (Internal or External) must be enabled.",
1206                );
1207            }
1208            internal_entry.set_sensitive(switch.is_active());
1209        }
1210    ));
1211
1212    external_switch.connect_active_notify(clone!(
1213        #[weak]
1214        internal_switch,
1215        #[weak]
1216        external_entry,
1217        #[weak]
1218        window,
1219        move |switch| {
1220            if !switch.is_active() && !internal_switch.is_active() {
1221                switch.set_active(true);
1222                show_alert(
1223                    &window,
1224                    "Invalid Selection",
1225                    "At least one URL (Internal or External) must be enabled.",
1226                );
1227            }
1228            external_entry.set_sensitive(switch.is_active());
1229        }
1230    ));
1231
1232    startup_row.connect_active_notify(clone!(
1233        #[strong]
1234        auto_apply_settings,
1235        move |_| {
1236            (auto_apply_settings)();
1237        }
1238    ));
1239
1240    metered_row.connect_active_notify(clone!(
1241        #[strong]
1242        auto_apply_settings,
1243        move |_| {
1244            (auto_apply_settings)();
1245        }
1246    ));
1247
1248    battery_row.connect_active_notify(clone!(
1249        #[strong]
1250        auto_apply_settings,
1251        move |_| {
1252            (auto_apply_settings)();
1253        }
1254    ));
1255
1256    notifications_row.connect_active_notify(clone!(
1257        #[strong]
1258        auto_apply_settings,
1259        move |_| {
1260            (auto_apply_settings)();
1261        }
1262    ));
1263
1264    catchup_row.connect_selected_notify(clone!(
1265        #[strong]
1266        auto_apply_settings,
1267        move |_| {
1268            (auto_apply_settings)();
1269        }
1270    ));
1271
1272    concurrency_row.connect_value_notify(clone!(
1273        #[strong]
1274        auto_apply_settings,
1275        move |_| {
1276            (auto_apply_settings)();
1277        }
1278    ));
1279
1280    quiet_hours_row.connect_active_notify(clone!(
1281        #[strong]
1282        auto_apply_settings,
1283        move |_| {
1284            (auto_apply_settings)();
1285        }
1286    ));
1287
1288    quiet_start_row.connect_value_notify(clone!(
1289        #[strong]
1290        auto_apply_settings,
1291        move |_| {
1292            (auto_apply_settings)();
1293        }
1294    ));
1295
1296    quiet_end_row.connect_value_notify(clone!(
1297        #[strong]
1298        auto_apply_settings,
1299        move |_| {
1300            (auto_apply_settings)();
1301        }
1302    ));
1303
1304    background_sync_row.connect_active_notify(clone!(
1305        #[strong]
1306        auto_apply_settings,
1307        move |_| {
1308            (auto_apply_settings)();
1309        }
1310    ));
1311
1312    border_width_row.connect_value_notify(clone!(
1313        #[strong]
1314        auto_apply_settings,
1315        move |_| {
1316            (auto_apply_settings)();
1317        }
1318    ));
1319
1320    border_color_btn.connect_rgba_notify(clone!(
1321        #[strong]
1322        auto_apply_settings,
1323        move |_| {
1324            (auto_apply_settings)();
1325        }
1326    ));
1327
1328    // If background sync is disabled AND this is the only open window, closing
1329    // settings should exit the app. When the library window is also open we
1330    // must not quit — the user explicitly opened settings *from* the library
1331    // and expects the library to stay around after dismissing settings.
1332    window.connect_close_request(clone!(
1333        #[strong]
1334        app_clone,
1335        #[strong]
1336        ctx,
1337        move |_| {
1338            // Read current background-sync state directly from config rather
1339            // than from a shadow RefCell, so any code path that mutates the
1340            // config (apply_settings, future autosave, etc.) is reflected
1341            // here without a separate book-keeping step.
1342            // The closing window is still in app.windows() at this point, so
1343            // a count of 1 means we're the only window left — quit the app.
1344            // > 1 means another window (typically the library) is open and
1345            // should keep running.
1346            let bg_sync = ctx.config.read().data.background_sync_enabled;
1347            if !bg_sync && app_clone.windows().len() <= 1 {
1348                app_clone.quit();
1349                return glib::Propagation::Stop;
1350            }
1351            glib::Propagation::Proceed
1352        }
1353    ));
1354
1355    // Background state poller — reads directly from in-memory shared state.
1356    // No disk I/O; the timer tears itself down when the window is destroyed.
1357    glib::timeout_add_local(
1358        Duration::from_millis(500),
1359        clone!(
1360            #[weak]
1361            status_row,
1362            #[weak]
1363            progress_bar,
1364            #[weak]
1365            route_row,
1366            #[weak]
1367            folders_row,
1368            #[weak]
1369            queue_health_row,
1370            #[weak]
1371            last_sync_row,
1372            #[weak]
1373            error_row,
1374            #[weak]
1375            pause_btn,
1376            #[strong]
1377            tracked_rows,
1378            #[upgrade_or]
1379            glib::ControlFlow::Break,
1380            move || {
1381                let (
1382                    status,
1383                    progress,
1384                    processed,
1385                    total,
1386                    failed,
1387                    current_file,
1388                    paused,
1389                    pause_reason,
1390                    pending,
1391                    route,
1392                    watched_folder_count,
1393                    last_successful_sync_at,
1394                    last_error,
1395                    last_error_guidance,
1396                    folder_subtitles,
1397                ) = {
1398                    let s = shared_state.lock();
1399                    let folder_subtitles = tracked_rows
1400                        .borrow()
1401                        .iter()
1402                        .map(|row_data| {
1403                            let mut final_subtitle = row_data.base_subtitle.clone();
1404                            if !final_subtitle.is_empty() {
1405                                final_subtitle.push('\n');
1406                            }
1407
1408                            if let Some(folder_status) = s.folder_statuses.get(&row_data.path) {
1409                                if let Some(err) = &folder_status.last_error {
1410                                    final_subtitle.push_str(&format!("Error: {}", err));
1411                                } else {
1412                                    let mut txt =
1413                                        format!("Pending: {}", folder_status.pending_count);
1414                                    if let Some(t) = folder_status.last_sync_at {
1415                                        txt.push_str(&format!(
1416                                            " - Last Sync: {}",
1417                                            format_sync_age(Some(t))
1418                                        ));
1419                                    }
1420                                    final_subtitle.push_str(&txt);
1421                                }
1422                            } else {
1423                                final_subtitle.push_str("Status: Idle");
1424                            }
1425
1426                            (row_data.action_row.clone(), final_subtitle)
1427                        })
1428                        .collect::<Vec<_>>();
1429
1430                    (
1431                        s.status.clone(),
1432                        s.progress,
1433                        s.processed_count,
1434                        s.total_queued,
1435                        s.failed_count,
1436                        s.current_file.clone().unwrap_or_else(|| "...".to_string()),
1437                        s.paused,
1438                        s.pause_reason.clone(),
1439                        s.queue_size,
1440                        s.active_server_route.clone(),
1441                        s.watched_folder_count,
1442                        s.last_successful_sync_at,
1443                        s.last_error.clone(),
1444                        s.last_error_guidance.clone(),
1445                        folder_subtitles,
1446                    )
1447                }; // lock released here
1448
1449                pause_btn.set_label(if paused { "Resume" } else { "Pause" });
1450                route_row.set_subtitle(
1451                    route
1452                        .as_deref()
1453                        .map(|route| match route {
1454                            "LAN" => "Connected through LAN",
1455                            "WAN" => "Connected through WAN",
1456                            _ => "Connected through configured server",
1457                        })
1458                        .unwrap_or("Waiting for a successful connection check"),
1459                );
1460                folders_row.set_subtitle(&format!("{} configured", watched_folder_count));
1461                queue_health_row
1462                    .set_subtitle(&format!("{} pending, {} waiting to retry", pending, failed));
1463                last_sync_row.set_subtitle(&format_sync_age(last_successful_sync_at));
1464                error_row.set_title(last_error.as_deref().unwrap_or("No recent errors"));
1465                error_row.set_subtitle(
1466                    last_error_guidance
1467                        .as_deref()
1468                        .unwrap_or("Uploads are healthy."),
1469                );
1470                for (row, subtitle) in folder_subtitles {
1471                    row.set_subtitle(&subtitle);
1472                }
1473
1474                if status == "paused" || paused {
1475                    status_row.set_title("Paused");
1476                    status_row.set_subtitle(
1477                        pause_reason
1478                            .as_deref()
1479                            .unwrap_or("Sync has been temporarily paused."),
1480                    );
1481                    progress_bar.set_fraction((progress as f64) / 100.0);
1482                } else if status == "idle" {
1483                    if failed > 0 {
1484                        status_row.set_title("Offline / Waiting");
1485                        status_row.set_subtitle(&format!("{} item(s) pending network", failed));
1486                        progress_bar.set_fraction(1.0);
1487                    } else {
1488                        status_row.set_title("Idle");
1489                        status_row.set_subtitle(&format!(
1490                            "Successfully processed {} file(s)",
1491                            processed.saturating_sub(failed)
1492                        ));
1493                        progress_bar.set_fraction(if processed > 0 { 1.0 } else { 0.0 });
1494                    }
1495                } else if status == "uploading" {
1496                    let filename = std::path::Path::new(&current_file)
1497                        .file_name()
1498                        .map(|n| n.to_string_lossy())
1499                        .unwrap_or_else(|| std::borrow::Cow::Borrowed("..."));
1500                    status_row.set_title(&format!("Uploading ({}/{})", processed, total));
1501                    status_row.set_subtitle(&filename);
1502                    progress_bar.set_fraction((progress as f64) / 100.0);
1503                }
1504
1505                glib::ControlFlow::Continue
1506            }
1507        ),
1508    );
1509    window.present();
1510}
1511
1512#[cfg(test)]
1513mod tests {
1514    use super::format_sync_age;
1515    use std::time::{SystemTime, UNIX_EPOCH};
1516
1517    #[test]
1518    fn test_format_sync_age_for_missing_timestamp() {
1519        assert_eq!(format_sync_age(None), "No successful sync yet");
1520    }
1521
1522    #[test]
1523    fn test_format_sync_age_for_recent_timestamp() {
1524        let now = SystemTime::now()
1525            .duration_since(UNIX_EPOCH)
1526            .unwrap_or_default()
1527            .as_secs_f64();
1528        assert_eq!(format_sync_age(Some(now - 30.0)), "Less than a minute ago");
1529    }
1530}