Skip to main content

studio_worker/ui/tabs/
config.rs

1//! Config tab — user-editable subset of [`Config`] reachable as
2//! widgets.  Save writes through `crate::config::save`; the runtime
3//! loops pick up the new values on their next tick because every
4//! tick snapshots `Arc<Mutex<Config>>`.
5//!
6//! Internal state (`worker_id`, `auth_token`, `install_id`,
7//! `registration_*`) is deliberately not surfaced here.  The
8//! auto-register flow owns it end-to-end.
9
10use std::path::{Path, PathBuf};
11
12use eframe::egui;
13
14use crate::config::{self, default_models_root, Config};
15
16use super::super::notifier::NotificationPrefs;
17use crate::autostart;
18
19/// Buffer the user is editing.  `dirty` is true when any field
20/// differs from `original`; Save / Reset clear it.
21#[derive(Debug, Clone)]
22pub struct ConfigDraft {
23    pub current: Config,
24    pub original: Config,
25    pub last_save_error: Option<String>,
26    /// Last autostart-toggle failure, surfaced next to the toggle so
27    /// the operator sees why it did not stick (the checkbox otherwise
28    /// silently reverts on the next frame because `is_enabled()`
29    /// re-reads disk).
30    pub autostart_error: Option<String>,
31}
32
33impl ConfigDraft {
34    pub fn from(cfg: &Config) -> Self {
35        Self {
36            current: cfg.clone(),
37            original: cfg.clone(),
38            last_save_error: None,
39            autostart_error: None,
40        }
41    }
42
43    pub fn dirty(&self) -> bool {
44        !configs_equal(&self.current, &self.original)
45    }
46
47    pub fn save(&mut self, path: &Path) -> Result<(), String> {
48        match config::save(&self.current, path) {
49            Ok(()) => {
50                // An operator deliberately applying settings through the
51                // window is a discrete, rare action that warrants an
52                // info-level breadcrumb — unlike `config::save`, which
53                // the auto-register poll loop calls every tick and so
54                // logs the routine persist at debug.  Emitting here (not
55                // in `config::save`) keeps that hot path quiet while
56                // surfacing operator-driven changes in default logs.
57                // Only non-secret, user-editable fields are named.
58                let changed = changed_fields(&self.original, &self.current).join(",");
59                tracing::info!(
60                    target: "studio_worker::ui::config",
61                    changed = ?changed,
62                    vram_threshold_gb = self.current.vram_threshold_gb,
63                    auto_start = self.current.auto_start,
64                    auto_update_enabled = self.current.auto_update_enabled,
65                    models_root = %self.current.models_root.display(),
66                    "operator applied config changes via UI"
67                );
68                self.original = self.current.clone();
69                self.last_save_error = None;
70                Ok(())
71            }
72            Err(e) => {
73                let msg = format!("{e}");
74                self.last_save_error = Some(msg.clone());
75                Err(msg)
76            }
77        }
78    }
79
80    pub fn reset(&mut self) {
81        self.current = self.original.clone();
82        self.last_save_error = None;
83        self.autostart_error = None;
84    }
85}
86
87/// Equality over the persisted, user-editable fields.  Internal state
88/// (registration ids, auth token, worker id, install id) is excluded
89/// because the UI never mutates it; the auto-register flow owns it.
90///
91/// Delegates to [`changed_fields`] so the dirty-check and the save
92/// breadcrumb can never drift: any field that dirties the form is, by
93/// construction, also named when the operator applies it.
94fn configs_equal(a: &Config, b: &Config) -> bool {
95    changed_fields(a, b).is_empty()
96}
97
98/// Names of the user-editable fields that differ between `a` and `b`,
99/// in declaration order.  Backs both the dirty-check ([`configs_equal`])
100/// and the operator-apply breadcrumb in [`ConfigDraft::save`], so the
101/// two share a single source of truth for "what the UI can change".
102fn changed_fields(a: &Config, b: &Config) -> Vec<&'static str> {
103    let mut fields = Vec::new();
104    if a.api_base_url != b.api_base_url {
105        fields.push("api_base_url");
106    }
107    if (a.vram_threshold_gb - b.vram_threshold_gb).abs() >= f32::EPSILON {
108        fields.push("vram_threshold_gb");
109    }
110    if a.auto_start != b.auto_start {
111        fields.push("auto_start");
112    }
113    if a.start_minimised != b.start_minimised {
114        fields.push("start_minimised");
115    }
116    if a.auto_update_enabled != b.auto_update_enabled {
117        fields.push("auto_update_enabled");
118    }
119    if a.auto_update_interval_secs != b.auto_update_interval_secs {
120        fields.push("auto_update_interval_secs");
121    }
122    if a.auto_update_feed != b.auto_update_feed {
123        fields.push("auto_update_feed");
124    }
125    if a.auto_update_prerelease != b.auto_update_prerelease {
126        fields.push("auto_update_prerelease");
127    }
128    if a.models_root != b.models_root {
129        fields.push("models_root");
130    }
131    fields
132}
133
134pub fn render(
135    ui: &mut egui::Ui,
136    draft: &mut ConfigDraft,
137    config_path: &Path,
138    notification_prefs: &mut NotificationPrefs,
139) -> bool {
140    let mut saved = false;
141    ui.heading("Configuration");
142    ui.label(
143        egui::RichText::new(format!("{}", config_path.display()))
144            .color(egui::Color32::from_gray(150))
145            .small(),
146    );
147    ui.add_space(8.0);
148
149    section(ui, "Connection", |ui| {
150        labeled_text(ui, "API base URL", &mut draft.current.api_base_url);
151    });
152
153    section(ui, "Worker", |ui| {
154        labeled_slider(
155            ui,
156            "VRAM threshold (GB)",
157            &mut draft.current.vram_threshold_gb,
158            0.0,
159            96.0,
160        );
161        labeled_bool(ui, "Auto-start on boot", &mut draft.current.auto_start);
162    });
163
164    section(ui, "Auto-update", |ui| {
165        labeled_bool(
166            ui,
167            "Auto-update enabled",
168            &mut draft.current.auto_update_enabled,
169        );
170        labeled_u64(
171            ui,
172            "Interval (seconds)",
173            &mut draft.current.auto_update_interval_secs,
174        );
175        labeled_text(ui, "Release feed URL", &mut draft.current.auto_update_feed);
176        labeled_bool(
177            ui,
178            "Track pre-releases",
179            &mut draft.current.auto_update_prerelease,
180        );
181    });
182
183    section(ui, "Models", |ui| {
184        labeled_folder(ui, "Models root", &mut draft.current.models_root);
185        ui.label("");
186        ui.label(
187            egui::RichText::new(
188                "This is where the models will be stored.  You might need a fair bit \
189                 of disk space to be able to satisfy different types of jobs.",
190            )
191            .italics()
192            .color(egui::Color32::from_gray(160)),
193        );
194        ui.end_row();
195    });
196
197    section(ui, "Notifications", |ui| {
198        ui.label("On job completion");
199        ui.checkbox(&mut notification_prefs.on_completion, "");
200        ui.end_row();
201        ui.label("On job failure");
202        ui.checkbox(&mut notification_prefs.on_failure, "");
203        ui.end_row();
204    });
205
206    let mut autostart_enabled = autostart::is_enabled();
207    let prev_autostart = autostart_enabled;
208    section(ui, "Background mode", |ui| {
209        ui.label("Run in tray on login");
210        ui.checkbox(&mut autostart_enabled, "");
211        ui.end_row();
212        ui.label("Start minimised");
213        ui.checkbox(&mut draft.current.start_minimised, "");
214        ui.end_row();
215    });
216    if autostart_enabled != prev_autostart {
217        let outcome = match std::env::current_exe() {
218            Ok(exe) if autostart_enabled => autostart::enable(&exe),
219            Ok(_) => autostart::disable(),
220            Err(e) => Err(anyhow::anyhow!("cannot resolve current executable: {e}")),
221        };
222        // `autostart::enable`/`disable` already emit a structured
223        // tracing event; surface any failure in the UI too so the
224        // operator sees why the toggle did not stick instead of it
225        // silently reverting on the next frame.
226        draft.autostart_error = outcome.err().map(|e| format!("{e}"));
227    }
228    if let Some(err) = &draft.autostart_error {
229        ui.colored_label(
230            egui::Color32::LIGHT_RED,
231            format!("could not change autostart: {err}"),
232        );
233    }
234
235    ui.add_space(12.0);
236    ui.horizontal(|ui| {
237        let dirty = draft.dirty();
238        let save = ui.add_enabled(dirty, egui::Button::new("Save"));
239        if save.clicked() {
240            saved = draft.save(config_path).is_ok();
241        }
242        if ui.add_enabled(dirty, egui::Button::new("Reset")).clicked() {
243            draft.reset();
244        }
245        if let Some(err) = &draft.last_save_error {
246            ui.colored_label(egui::Color32::LIGHT_RED, format!("save failed: {err}"));
247        } else if !dirty && draft.last_save_error.is_none() {
248            ui.label(
249                egui::RichText::new("up to date")
250                    .italics()
251                    .color(egui::Color32::from_gray(150)),
252            );
253        }
254    });
255    saved
256}
257
258// ---------------------------------------------------------------------------
259// Widget helpers
260// ---------------------------------------------------------------------------
261
262fn section(ui: &mut egui::Ui, title: &str, add: impl FnOnce(&mut egui::Ui)) {
263    egui::CollapsingHeader::new(title)
264        .default_open(true)
265        .show(ui, |ui| {
266            egui::Grid::new(title)
267                .num_columns(2)
268                .spacing([12.0, 6.0])
269                .show(ui, |ui| {
270                    add(ui);
271                });
272        });
273    ui.add_space(4.0);
274}
275
276fn labeled_text(ui: &mut egui::Ui, label: &str, value: &mut String) {
277    ui.label(label);
278    ui.add(egui::TextEdit::singleline(value).desired_width(360.0));
279    ui.end_row();
280}
281
282fn labeled_bool(ui: &mut egui::Ui, label: &str, value: &mut bool) {
283    ui.label(label);
284    ui.checkbox(value, "");
285    ui.end_row();
286}
287
288fn labeled_slider(ui: &mut egui::Ui, label: &str, value: &mut f32, min: f32, max: f32) {
289    ui.label(label);
290    ui.add(egui::Slider::new(value, min..=max).fixed_decimals(1));
291    ui.end_row();
292}
293
294fn labeled_u64(ui: &mut egui::Ui, label: &str, value: &mut u64) {
295    ui.label(label);
296    let mut buf = value.to_string();
297    if ui
298        .add(egui::TextEdit::singleline(&mut buf).desired_width(120.0))
299        .changed()
300    {
301        if let Ok(n) = buf.parse::<u64>() {
302            *value = n;
303        }
304    }
305    ui.end_row();
306}
307
308/// Path-with-folder-picker widget.  The text edit reflects the
309/// current value at all times; the "Browse…" button opens the
310/// native picker (rfd) and overwrites it on confirm.
311fn labeled_folder(ui: &mut egui::Ui, label: &str, value: &mut PathBuf) {
312    ui.label(label);
313    ui.horizontal(|ui| {
314        let mut buf = value.to_string_lossy().to_string();
315        let r = ui.add(egui::TextEdit::singleline(&mut buf).desired_width(280.0));
316        if r.changed() {
317            *value = PathBuf::from(buf);
318        }
319        if ui.button("Browse…").clicked() {
320            let starting = if value.is_absolute() {
321                value.clone()
322            } else {
323                default_models_root()
324            };
325            if let Some(picked) = rfd::FileDialog::new()
326                .set_directory(starting.parent().unwrap_or(&starting))
327                .pick_folder()
328            {
329                *value = picked;
330            }
331        }
332    });
333    ui.end_row();
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339    use tempfile::tempdir;
340
341    #[test]
342    fn draft_starts_clean() {
343        let cfg = Config::default();
344        let draft = ConfigDraft::from(&cfg);
345        assert!(!draft.dirty());
346    }
347
348    #[test]
349    fn draft_marks_dirty_after_edit() {
350        let cfg = Config::default();
351        let mut draft = ConfigDraft::from(&cfg);
352        draft.current.vram_threshold_gb = 24.0;
353        assert!(draft.dirty());
354    }
355
356    #[test]
357    fn draft_marks_dirty_when_models_root_changes() {
358        let cfg = Config::default();
359        let mut draft = ConfigDraft::from(&cfg);
360        draft.current.models_root = PathBuf::from("/tmp/other-models");
361        assert!(draft.dirty());
362    }
363
364    #[test]
365    fn save_writes_through_and_clears_dirty() {
366        let dir = tempdir().unwrap();
367        let path = dir.path().join("config.toml");
368        let cfg = Config::default();
369        let mut draft = ConfigDraft::from(&cfg);
370        draft.current.vram_threshold_gb = 24.0;
371        draft.save(&path).unwrap();
372        assert!(!draft.dirty());
373        // Reload from disk and confirm the value persisted.
374        let (loaded, _) = config::load(Some(&path.to_string_lossy())).unwrap();
375        assert!((loaded.vram_threshold_gb - 24.0).abs() < f32::EPSILON);
376    }
377
378    #[test]
379    fn reset_reverts_unsaved_edits() {
380        let cfg = Config::default();
381        let mut draft = ConfigDraft::from(&cfg);
382        draft.current.vram_threshold_gb = 33.0;
383        draft.reset();
384        assert!((draft.current.vram_threshold_gb - cfg.vram_threshold_gb).abs() < f32::EPSILON);
385        assert!(!draft.dirty());
386    }
387
388    #[test]
389    fn reset_clears_autostart_error() {
390        let cfg = Config::default();
391        let mut draft = ConfigDraft::from(&cfg);
392        draft.autostart_error = Some("boom".into());
393        draft.reset();
394        assert!(draft.autostart_error.is_none());
395    }
396
397    #[test]
398    fn changed_fields_names_only_differing_user_editable_fields() {
399        let base = Config::default();
400        let mut edited = base.clone();
401        edited.vram_threshold_gb = base.vram_threshold_gb + 8.0;
402        edited.models_root = PathBuf::from("/tmp/other-models");
403        let changed = changed_fields(&base, &edited);
404        assert_eq!(changed, vec!["vram_threshold_gb", "models_root"]);
405    }
406
407    #[test]
408    fn changed_fields_is_empty_for_identical_configs() {
409        let cfg = Config::default();
410        assert!(changed_fields(&cfg, &cfg).is_empty());
411    }
412
413    #[test]
414    fn save_emits_operator_apply_breadcrumb() {
415        use crate::test_support::capture;
416        let dir = tempdir().unwrap();
417        let path = dir.path().join("config.toml");
418        let logs = capture(move || {
419            let cfg = Config::default();
420            let mut draft = ConfigDraft::from(&cfg);
421            draft.current.vram_threshold_gb = 24.0;
422            draft.save(&path).expect("save must succeed");
423        });
424        assert!(logs.contains("INFO"), "expected INFO level, got: {logs}");
425        assert!(
426            logs.contains("studio_worker::ui::config"),
427            "expected ui::config target, got: {logs}"
428        );
429        assert!(
430            logs.contains("changed=\"vram_threshold_gb\""),
431            "expected the changed field list, got: {logs}"
432        );
433        assert!(
434            logs.contains("operator applied config changes via UI"),
435            "expected the apply message, got: {logs}"
436        );
437    }
438
439    #[test]
440    fn save_failure_records_last_save_error() {
441        let cfg = Config::default();
442        let mut draft = ConfigDraft::from(&cfg);
443        // /proc is read-only on Linux — a write attempt fails.
444        let bad = Path::new("/proc/this-should-fail/config.toml");
445        let res = draft.save(bad);
446        assert!(res.is_err());
447        assert!(draft.last_save_error.is_some());
448    }
449}