Skip to main content

mimick/settings_window/
queue_inspector.rs

1//! Queue inspector dialog -- browse failed tasks and recent queue activity.
2//!
3//! Lists the current retry queue with per-item retry buttons and a bulk
4//! retry-all action. A scrollable activity feed shows recent upload
5//! attempts, statuses, and error details. The dialog refreshes live via
6//! a periodic timer so background state changes are always visible.
7
8use std::path::Path;
9use std::sync::Arc;
10
11use adw::prelude::*;
12use glib::clone;
13use gtk::prelude::*;
14use gtk::{Box, Button, ListBox, Orientation, ScrolledWindow};
15use libadwaita as adw;
16
17use crate::queue_manager::QueueManager;
18use crate::state_manager::QueueEvent;
19
20/// Construct and present the modal Queue Inspector window.
21pub fn show_queue_inspector(
22    parent: &impl gtk::prelude::IsA<gtk::Window>,
23    queue_manager: Arc<QueueManager>,
24) {
25    let dialog = adw::Window::builder()
26        .transient_for(parent)
27        .modal(true)
28        .title("Queue Inspector")
29        .default_width(900)
30        .default_height(680)
31        .width_request(360)
32        .height_request(480)
33        .build();
34
35    let header = adw::HeaderBar::builder().show_title(true).build();
36
37    let content = Box::builder()
38        .orientation(Orientation::Vertical)
39        .spacing(12)
40        .margin_top(12)
41        .margin_bottom(12)
42        .margin_start(12)
43        .margin_end(12)
44        .build();
45
46    let main_scroll = ScrolledWindow::builder()
47        .hscrollbar_policy(gtk::PolicyType::Never)
48        .child(&content)
49        .build();
50
51    let toolbar = adw::ToolbarView::builder().build();
52    toolbar.add_top_bar(&header);
53    toolbar.set_content(Some(&main_scroll));
54    dialog.set_content(Some(&toolbar));
55
56    let actions = Box::builder()
57        .orientation(Orientation::Horizontal)
58        .spacing(8)
59        .halign(gtk::Align::End)
60        .build();
61    content.append(&actions);
62
63    let retry_all_btn = Button::builder().label("Retry All Failed").build();
64    let clear_failed_btn = Button::builder().label("Clear Failed Queue").build();
65    actions.append(&retry_all_btn);
66    actions.append(&clear_failed_btn);
67
68    let failed_group = adw::PreferencesGroup::builder()
69        .title("Failed Retry Queue")
70        .build();
71    content.append(&failed_group);
72
73    let events_group = adw::PreferencesGroup::builder()
74        .title("Recent Queue Activity")
75        .build();
76    content.append(&events_group);
77
78    let events_list = ListBox::builder()
79        .selection_mode(gtk::SelectionMode::None)
80        .css_classes(vec!["boxed-list".to_string()])
81        .build();
82    events_group.add(&events_list);
83
84    // Initial population.
85    refresh_inspector(&failed_group, &events_list, &queue_manager);
86
87    // Wire action buttons with immediate UI refresh.
88    let qm_retry_all = queue_manager.clone();
89    let fg_retry = failed_group.clone();
90    let el_retry = events_list.clone();
91    retry_all_btn.connect_clicked(move |btn| {
92        btn.set_sensitive(false);
93        let qm = qm_retry_all.clone();
94        let fg = fg_retry.clone();
95        let el = el_retry.clone();
96        glib::MainContext::default().spawn_local(async move {
97            let _ = qm.retry_all_failed().await;
98            refresh_inspector(&fg, &el, &qm);
99        });
100    });
101
102    let qm_clear = queue_manager.clone();
103    let fg_clear = failed_group.clone();
104    let el_clear = events_list.clone();
105    clear_failed_btn.connect_clicked(move |_| {
106        let _ = qm_clear.clear_failed();
107        refresh_inspector(&fg_clear, &el_clear, &qm_clear);
108    });
109
110    // Live-update timer: refresh every second while the dialog exists.
111    let prev_failed_count = std::cell::Cell::new(queue_manager.failed_tasks().len());
112    let prev_event_count = std::cell::Cell::new(queue_manager.recent_events().len());
113    let qm_tick = queue_manager.clone();
114    glib::timeout_add_local(
115        std::time::Duration::from_secs(1),
116        clone!(
117            #[weak]
118            failed_group,
119            #[weak]
120            events_list,
121            #[upgrade_or]
122            glib::ControlFlow::Break,
123            move || {
124                let failed = qm_tick.failed_tasks();
125                let events = qm_tick.recent_events();
126                let changed = failed.len() != prev_failed_count.get()
127                    || events.len() != prev_event_count.get()
128                    || has_status_change(&events, &failed);
129                if changed {
130                    prev_failed_count.set(failed.len());
131                    prev_event_count.set(events.len());
132                    refresh_inspector(&failed_group, &events_list, &qm_tick);
133                }
134                glib::ControlFlow::Continue
135            }
136        ),
137    );
138
139    let bp = adw::Breakpoint::new(
140        adw::BreakpointCondition::parse("max-width: 500sp").expect("valid breakpoint condition"),
141    );
142    bp.add_setter(&retry_all_btn, "label", Some(&"Retry All".to_value()));
143    bp.add_setter(&clear_failed_btn, "label", Some(&"Clear".to_value()));
144
145    dialog.add_breakpoint(bp);
146
147    dialog.present();
148}
149
150/// Detect whether event statuses differ from what's currently displayed.
151fn has_status_change(events: &[QueueEvent], failed: &[crate::queue_manager::FileTask]) -> bool {
152    // Use a simple heuristic: check the latest event timestamp and failed paths.
153    let latest_ts = events.first().map(|e| e.timestamp).unwrap_or(0.0);
154    let failed_sig: u64 = failed.iter().map(|t| t.path.len() as u64).sum();
155    // Combine into a single value that changes when anything meaningful changes.
156    let sig = (latest_ts * 1000.0) as u64 ^ failed_sig;
157    // We store nothing here -- the caller compares counts. This catches
158    // same-count but different-content changes (e.g. a retry replaced a
159    // failed item). The XOR with timestamp makes it very unlikely to miss.
160    sig != 0
161}
162
163/// Clear and rebuild both sections from current QueueManager state.
164fn refresh_inspector(
165    failed_group: &adw::PreferencesGroup,
166    events_list: &ListBox,
167    queue_manager: &Arc<QueueManager>,
168) {
169    let failed_tasks = queue_manager.failed_tasks();
170    let events = queue_manager.recent_events();
171    rebuild_failed_rows(failed_group, &failed_tasks, queue_manager);
172    rebuild_event_rows(events_list, &events);
173}
174
175/// Remove all children from a PreferencesGroup.
176fn clear_preferences_group(group: &adw::PreferencesGroup) {
177    while let Some(child) = group
178        .first_child()
179        .and_then(|c| c.last_child())
180        .and_then(|c| c.last_child())
181        .and_then(|c| c.first_child())
182    {
183        if child.downcast_ref::<adw::ActionRow>().is_some() {
184            group.remove(&child);
185        } else {
186            break;
187        }
188    }
189}
190
191/// Build the "Failed Retry Queue" rows.
192fn rebuild_failed_rows(
193    group: &adw::PreferencesGroup,
194    tasks: &[crate::queue_manager::FileTask],
195    queue_manager: &Arc<QueueManager>,
196) {
197    clear_preferences_group(group);
198
199    if tasks.is_empty() {
200        group.add(
201            &adw::ActionRow::builder()
202                .title("No failed items")
203                .subtitle("The retry queue is currently empty.")
204                .build(),
205        );
206        return;
207    }
208
209    for task in tasks {
210        let row = adw::ActionRow::builder()
211            .title(
212                Path::new(&task.path)
213                    .file_name()
214                    .and_then(|name| name.to_str())
215                    .unwrap_or(task.path.as_str()),
216            )
217            .subtitle(&task.path)
218            .title_lines(0)
219            .subtitle_lines(3)
220            .build();
221        let retry_btn = Button::builder().label("Retry").build();
222        let task_path = task.path.clone();
223        let qm = queue_manager.clone();
224        let group_ref = group.clone();
225        retry_btn.connect_clicked(move |btn| {
226            btn.set_sensitive(false);
227            btn.set_label("Retrying\u{2026}");
228            let qm = qm.clone();
229            let path = task_path.clone();
230            let group_ref = group_ref.clone();
231            glib::MainContext::default().spawn_local(async move {
232                let _ = qm.retry_failed_path(&path).await;
233                let refreshed = qm.failed_tasks();
234                rebuild_failed_rows(&group_ref, &refreshed, &qm);
235            });
236        });
237        row.add_suffix(&retry_btn);
238        group.add(&row);
239    }
240}
241
242/// Build the "Recent Queue Activity" event rows.
243fn rebuild_event_rows(list: &ListBox, events: &[QueueEvent]) {
244    list.remove_all();
245
246    for event in events {
247        let row = adw::ActionRow::builder()
248            .title(
249                Path::new(&event.path)
250                    .file_name()
251                    .and_then(|name| name.to_str())
252                    .unwrap_or(event.path.as_str()),
253            )
254            .subtitle(format!(
255                "{} | attempts={}{}",
256                event.status,
257                event.attempts,
258                event
259                    .detail
260                    .as_ref()
261                    .map(|detail| format!(" | {}", detail))
262                    .unwrap_or_default()
263            ))
264            .title_lines(0)
265            .subtitle_lines(4)
266            .build();
267
268        list.append(&row);
269    }
270}
271
272/// Construct and present the Libadwaita standard About Dialog for the application.
273pub(super) fn show_about_dialog(parent: &impl gtk::prelude::IsA<gtk::Widget>) {
274    let about = adw::AboutDialog::builder()
275        .application_name("Mimick")
276        .application_icon("dev.nicx.mimick")
277        .version(env!("CARGO_PKG_VERSION"))
278        .developer_name("Nick Cardoso")
279        .website("https://github.com/nicx17/mimick")
280        .issue_url("https://github.com/nicx17/mimick/issues")
281        .license_type(gtk::License::Gpl30)
282        .build();
283
284    about.add_credit_section(
285        Some("Icon Design"),
286        &["Round Icons https://unsplash.com/illustrations/a-white-and-orange-flower-on-a-white-background-IkQ_WrJzZOM"],
287    );
288
289    let third_party =
290        glib::markup_escape_text(include_str!("../../THIRD_PARTY_LICENSES_SUMMARY.txt"));
291    about.add_legal_section(
292        "Third-party Licenses",
293        None,
294        gtk::License::Custom,
295        Some(&third_party),
296    );
297
298    about.present(Some(parent));
299}