Skip to content

Commit 99f8f88

Browse files
committed
feat(dialogs): improve CreateDistroboxDialog validation and UX
- Guided section: disable Create button until valid name is entered - Guided section: show toast notifications for validation errors - From File section: filter to only show *.ini files - From URL section: add apply button with async URL validation - From URL section: validate URL connectivity before enabling Create - Add ToastOverlay for displaying validation error messages The URL validation uses the command_runner abstraction for Flatpak compatibility, executing curl to verify URL connectivity.
1 parent 511a022 commit 99f8f88

File tree

1 file changed

+119
-9
lines changed

1 file changed

+119
-9
lines changed

src/dialogs/create_distrobox_dialog.rs

Lines changed: 119 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ use adw::prelude::*;
22
use adw::subclass::prelude::*;
33
use gtk::gio::File;
44
use gtk::{gio, glib};
5+
use std::cell::Cell;
56
use tracing::error;
67

78
use crate::backends::{self, CreateArgName, CreateArgs, Error};
9+
use crate::fakers::Command;
810
use crate::i18n::gettext;
911
use crate::models::Container;
1012
use crate::root_store::RootStore;
@@ -34,8 +36,11 @@ mod imp {
3436
pub dialog: adw::Dialog,
3537
pub navigation_view: adw::NavigationView,
3638
pub toolbar_view: adw::ToolbarView,
39+
pub toast_overlay: adw::ToastOverlay,
3740
pub content: gtk::Box,
3841
pub name_row: adw::EntryRow,
42+
#[property(get, set)]
43+
pub url_validated: Cell<bool>,
3944
pub image_row: adw::ActionRow,
4045
pub images_model: gtk::StringList,
4146
pub selected_image: RefCell<String>,
@@ -181,6 +186,7 @@ mod imp {
181186
let home_row = self.obj().build_file_row(
182187
&gettext("Select Home Directory"),
183188
FileRowSelection::Folder,
189+
None, // No filter for folders
184190
move |path| {
185191
obj.set_home_folder(Some(path.display().to_string()));
186192
},
@@ -219,6 +225,7 @@ mod imp {
219225

220226
let create_btn = gtk::Button::with_label(&gettext("Create"));
221227
create_btn.set_halign(gtk::Align::Center);
228+
create_btn.set_sensitive(false); // Initially disabled until name is valid
222229

223230
let obj = self.obj();
224231
create_btn.connect_clicked(clone!(
@@ -247,6 +254,18 @@ mod imp {
247254

248255
self.content.append(&create_btn);
249256

257+
// Add name validation for Create button sensitivity
258+
let guided_create_btn = create_btn.clone();
259+
self.name_row.connect_changed(clone!(
260+
#[weak]
261+
guided_create_btn,
262+
move |entry| {
263+
let text = entry.text();
264+
let is_valid = !text.is_empty() && backends::CreateArgName::new(&text).is_ok();
265+
guided_create_btn.set_sensitive(is_valid);
266+
}
267+
));
268+
250269
// Prefill wiring: debounce name changes to suggest an image when user hasn't interacted
251270
let obj_for_prefill = self.obj().clone();
252271
let name_row = obj_for_prefill.imp().name_row.clone();
@@ -307,10 +326,15 @@ mod imp {
307326
assemble_group
308327
.set_description(Some(&gettext("Create a container from an assemble file")));
309328

329+
let ini_filter = gtk::FileFilter::new();
330+
ini_filter.set_name(Some(&gettext("INI Files")));
331+
ini_filter.add_pattern("*.ini");
332+
310333
let obj = self.obj().clone();
311334
let file_row = self.obj().build_file_row(
312335
&gettext("Select Assemble File"),
313336
FileRowSelection::File,
337+
Some(&ini_filter),
314338
move |path| {
315339
obj.set_assemble_file(Some(path.display().to_string()));
316340
},
@@ -358,7 +382,9 @@ mod imp {
358382

359383
let url_row = adw::EntryRow::new();
360384
url_row.set_title(&gettext("URL"));
361-
url_row.set_text("https://example.com/container.yaml");
385+
url_row.set_text("https://example.com/container.ini");
386+
url_row.set_show_apply_button(true);
387+
362388

363389
url_group.add(&url_row);
364390
url_page.append(&url_group);
@@ -372,12 +398,60 @@ mod imp {
372398
create_btn.set_sensitive(false);
373399
url_page.append(&create_btn);
374400

375-
// Enable button when URL is entered
401+
// Store reference for use in multiple closures
402+
let url_create_btn = create_btn.clone();
403+
let obj_for_url = self.obj().clone();
404+
376405
url_row.connect_changed(clone!(
377406
#[weak]
378-
obj,
407+
obj_for_url,
408+
#[weak]
409+
url_create_btn,
379410
move |entry| {
380-
obj.set_assemble_url(Some(entry.text()));
411+
obj_for_url.set_assemble_url(Some(entry.text()));
412+
obj_for_url.set_url_validated(false);
413+
url_create_btn.set_sensitive(false);
414+
// Clear error CSS when user types
415+
entry.remove_css_class("error");
416+
}
417+
));
418+
419+
url_row.connect_apply(clone!(
420+
#[weak]
421+
obj_for_url,
422+
#[weak]
423+
url_create_btn,
424+
move |entry| {
425+
let url = entry.text().to_string();
426+
if url.is_empty() {
427+
return;
428+
}
429+
430+
// Reset validation state
431+
obj_for_url.set_url_validated(false);
432+
url_create_btn.set_sensitive(false);
433+
434+
glib::MainContext::ref_thread_default().spawn_local(clone!(
435+
#[weak]
436+
obj_for_url,
437+
#[weak]
438+
url_create_btn,
439+
#[weak]
440+
entry,
441+
async move {
442+
let is_valid = obj_for_url.validate_url(&url).await;
443+
obj_for_url.set_url_validated(is_valid);
444+
url_create_btn.set_sensitive(is_valid);
445+
446+
if !is_valid {
447+
let toast = adw::Toast::new(&gettext("Could not connect to URL"));
448+
obj_for_url.imp().toast_overlay.add_toast(toast);
449+
entry.add_css_class("error");
450+
} else {
451+
entry.remove_css_class("error");
452+
}
453+
}
454+
));
381455
}
382456
));
383457

@@ -393,10 +467,6 @@ mod imp {
393467
}
394468
));
395469

396-
obj.connect_assemble_url_notify(move |obj| {
397-
create_btn.set_sensitive(obj.assemble_url().is_some());
398-
});
399-
400470
// Add pages to view stack
401471
view_stack.add_titled(&self.content, Some("create"), "Guided");
402472
view_stack.add_titled(&assemble_page, Some("assemble-file"), "From File");
@@ -420,9 +490,12 @@ mod imp {
420490
scrolled_window.set_propagate_natural_height(true);
421491
scrolled_window.set_child(Some(&content_box));
422492

493+
// Wrap in toast overlay for showing notifications
494+
self.toast_overlay.set_child(Some(&scrolled_window));
495+
423496
toolbar_view.add_top_bar(&header);
424497
toolbar_view.set_vexpand(true);
425-
toolbar_view.set_content(Some(&scrolled_window));
498+
toolbar_view.set_content(Some(&self.toast_overlay));
426499

427500
let page = adw::NavigationPage::new(toolbar_view, "Create a Distrobox");
428501
navigation_view.add(&page);
@@ -489,6 +562,7 @@ impl CreateDistroboxDialog {
489562
&self,
490563
title: &str,
491564
selection: FileRowSelection,
565+
filter: Option<&gtk::FileFilter>,
492566
cb: impl Fn(PathBuf) + Clone + 'static,
493567
) -> adw::ActionRow {
494568
let row = adw::ActionRow::new();
@@ -500,6 +574,7 @@ impl CreateDistroboxDialog {
500574
row.add_suffix(&file_icon);
501575

502576
let title = title.to_owned();
577+
let filter = filter.cloned(); // Clone the Option<&FileFilter> to Option<FileFilter>
503578
let dialog_cb = clone!(
504579
#[weak(rename_to=this)]
505580
self,
@@ -535,6 +610,15 @@ impl CreateDistroboxDialog {
535610
);
536611
row.connect_activated(move |_| {
537612
let file_dialog = gtk::FileDialog::builder().title(&title).modal(true).build();
613+
614+
// Apply filter if provided
615+
if let Some(ref f) = filter {
616+
let filters = gio::ListStore::new::<gtk::FileFilter>();
617+
filters.append(f);
618+
file_dialog.set_filters(Some(&filters));
619+
file_dialog.set_default_filter(Some(f));
620+
}
621+
538622
let dialog_cb = dialog_cb.clone();
539623
match selection {
540624
FileRowSelection::File => {
@@ -859,8 +943,34 @@ impl CreateDistroboxDialog {
859943
Err(backends::Error::InvalidField(field, msg)) if field == "name" => {
860944
imp.name_row.add_css_class("error");
861945
imp.name_row.set_tooltip_text(Some(msg));
946+
// Show toast for name validation error
947+
let toast = adw::Toast::new(msg);
948+
imp.toast_overlay.add_toast(toast);
949+
}
950+
Err(backends::Error::InvalidField(_, msg)) => {
951+
// Show toast for other field validation errors
952+
let toast = adw::Toast::new(msg);
953+
imp.toast_overlay.add_toast(toast);
862954
}
863955
_ => {}
864956
}
865957
}
958+
959+
async fn validate_url(&self, url: &str) -> bool {
960+
// Use curl with HEAD request to validate URL
961+
// CRITICAL: Use self.root_store().command_runner() for Flatpak compatibility
962+
let command_runner = self.root_store().command_runner();
963+
let mut cmd = Command::new("curl");
964+
cmd.arg("-s"); // Silent
965+
cmd.arg("-f"); // Fail on HTTP errors
966+
cmd.arg("-I"); // HEAD request only
967+
cmd.arg("--connect-timeout");
968+
cmd.arg("5"); // 5 second connection timeout
969+
cmd.arg(url);
970+
971+
match command_runner.output(cmd).await {
972+
Ok(output) => output.status.success(),
973+
Err(_) => false,
974+
}
975+
}
866976
}

0 commit comments

Comments
 (0)