1use 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#[derive(Debug, Clone)]
22pub struct ConfigDraft {
23 pub current: Config,
24 pub original: Config,
25 pub last_save_error: Option<String>,
26 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 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
87fn configs_equal(a: &Config, b: &Config) -> bool {
95 changed_fields(a, b).is_empty()
96}
97
98fn 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 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
258fn 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
308fn 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 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 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}