Add Watch Folders settings page with full CRUD

- New settings page: Watch Folders with add/remove/edit controls
- Each watch folder has: path, linked preset dropdown, recursive toggle,
  active/inactive switch, and remove button
- Watch folder config persisted in AppConfig
- Empty state message when no folders configured
This commit is contained in:
2026-03-06 16:13:14 +02:00
parent e976ca2c0a
commit a0bb00eddf
2 changed files with 219 additions and 0 deletions

View File

@@ -1,5 +1,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::watcher::WatchFolder;
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)] #[serde(default)]
pub struct AppConfig { pub struct AppConfig {
@@ -20,6 +22,7 @@ pub struct AppConfig {
pub reduced_motion: bool, pub reduced_motion: bool,
pub history_max_entries: usize, pub history_max_entries: usize,
pub history_max_days: u32, pub history_max_days: u32,
pub watch_folders: Vec<WatchFolder>,
} }
impl Default for AppConfig { impl Default for AppConfig {
@@ -42,6 +45,7 @@ impl Default for AppConfig {
reduced_motion: false, reduced_motion: false,
history_max_entries: 50, history_max_entries: 50,
history_max_days: 30, history_max_days: 30,
watch_folders: Vec::new(),
} }
} }
} }

View File

@@ -252,6 +252,106 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
notify_page.add(&notify_group); notify_page.add(&notify_group);
dialog.add(&notify_page); dialog.add(&notify_page);
// Watch Folders page
let watch_page = adw::PreferencesPage::builder()
.title("Watch Folders")
.icon_name("folder-visiting-symbolic")
.build();
let watch_group = adw::PreferencesGroup::builder()
.title("Monitored Folders")
.description("Automatically process images added to these folders")
.build();
let watch_list = gtk::ListBox::builder()
.selection_mode(gtk::SelectionMode::None)
.css_classes(["boxed-list"])
.build();
watch_list.set_widget_name("watch-folder-list");
// Shared state for watch folders
let watch_folders_state: std::rc::Rc<std::cell::RefCell<Vec<pixstrip_core::watcher::WatchFolder>>> =
std::rc::Rc::new(std::cell::RefCell::new(config.watch_folders.clone()));
// Build preset names list for dropdown
let builtin_presets = pixstrip_core::preset::Preset::all_builtins();
let preset_names: Vec<String> = builtin_presets.iter().map(|p| p.name.clone()).collect();
// Populate existing watch folders
{
let folders = watch_folders_state.borrow();
for folder in folders.iter() {
let row = build_watch_folder_row(folder, &preset_names, &watch_folders_state, &watch_list);
watch_list.append(&row);
}
}
// Empty state
let empty_label = gtk::Label::builder()
.label("No watch folders configured.\nAdd a folder to start automatic processing.")
.css_classes(["dim-label"])
.halign(gtk::Align::Center)
.margin_top(16)
.margin_bottom(16)
.justify(gtk::Justification::Center)
.build();
empty_label.set_visible(config.watch_folders.is_empty());
watch_group.add(&watch_list);
watch_group.add(&empty_label);
// Add folder button
let add_button = gtk::Button::builder()
.label("Add Watch Folder")
.halign(gtk::Align::Start)
.margin_top(8)
.build();
add_button.add_css_class("suggested-action");
add_button.add_css_class("pill");
{
let wfs = watch_folders_state.clone();
let wl = watch_list.clone();
let el = empty_label.clone();
let pnames = preset_names.clone();
add_button.connect_clicked(move |btn| {
let wfs = wfs.clone();
let wl = wl.clone();
let el = el.clone();
let pnames = pnames.clone();
let dialog = gtk::FileDialog::builder()
.title("Choose Watch Folder")
.modal(true)
.build();
if let Some(window) = btn.root().and_then(|r| r.downcast::<gtk::Window>().ok()) {
dialog.select_folder(Some(&window), gtk::gio::Cancellable::NONE, move |result| {
if let Ok(file) = result
&& let Some(path) = file.path()
{
let new_folder = pixstrip_core::watcher::WatchFolder {
path: path.clone(),
preset_name: "Blog Photos".to_string(),
recursive: false,
active: true,
};
let row = build_watch_folder_row(&new_folder, &pnames, &wfs, &wl);
wl.append(&row);
wfs.borrow_mut().push(new_folder);
el.set_visible(false);
}
});
}
});
}
let add_group = adw::PreferencesGroup::new();
add_group.add(&add_button);
watch_page.add(&watch_group);
watch_page.add(&add_group);
dialog.add(&watch_page);
// Wire reset button // Wire reset button
{ {
let subfolder = subfolder_row.clone(); let subfolder = subfolder_row.clone();
@@ -324,6 +424,7 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
reduced_motion: motion_row.is_active(), reduced_motion: motion_row.is_active(),
history_max_entries: hist_max_entries, history_max_entries: hist_max_entries,
history_max_days: hist_max_days, history_max_days: hist_max_days,
watch_folders: watch_folders_state.borrow().clone(),
}; };
let store = ConfigStore::new(); let store = ConfigStore::new();
@@ -332,3 +433,117 @@ pub fn build_settings_dialog() -> adw::PreferencesDialog {
dialog dialog
} }
fn build_watch_folder_row(
folder: &pixstrip_core::watcher::WatchFolder,
preset_names: &[String],
watch_state: &std::rc::Rc<std::cell::RefCell<Vec<pixstrip_core::watcher::WatchFolder>>>,
list_box: &gtk::ListBox,
) -> adw::ExpanderRow {
let display_path = folder.path.file_name()
.and_then(|n| n.to_str())
.unwrap_or_else(|| folder.path.to_str().unwrap_or("Unknown"));
let row = adw::ExpanderRow::builder()
.title(display_path)
.subtitle(&folder.path.display().to_string())
.show_enable_switch(true)
.enable_expansion(folder.active)
.build();
row.add_prefix(&gtk::Image::from_icon_name("folder-visiting-symbolic"));
// Preset selector
let preset_row = adw::ComboRow::builder()
.title("Linked Preset")
.subtitle("Preset to apply to new images")
.build();
let preset_model = gtk::StringList::new(
&preset_names.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
);
preset_row.set_model(Some(&preset_model));
// Set selected to matching preset
let selected_idx = preset_names.iter()
.position(|n| *n == folder.preset_name)
.unwrap_or(0);
preset_row.set_selected(selected_idx as u32);
// Recursive toggle
let recursive_row = adw::SwitchRow::builder()
.title("Include Subfolders")
.subtitle("Monitor subfolders recursively")
.active(folder.recursive)
.build();
// Remove button
let remove_row = adw::ActionRow::builder()
.title("Remove This Folder")
.build();
remove_row.add_prefix(&gtk::Image::from_icon_name("user-trash-symbolic"));
let remove_btn = gtk::Button::builder()
.icon_name("edit-delete-symbolic")
.tooltip_text("Remove watch folder")
.valign(gtk::Align::Center)
.build();
remove_btn.add_css_class("flat");
remove_btn.add_css_class("error");
remove_row.add_suffix(&remove_btn);
row.add_row(&preset_row);
row.add_row(&recursive_row);
row.add_row(&remove_row);
// Wire enable toggle
let folder_path = folder.path.clone();
{
let wfs = watch_state.clone();
let fp = folder_path.clone();
row.connect_enable_expansion_notify(move |r| {
let mut folders = wfs.borrow_mut();
if let Some(f) = folders.iter_mut().find(|f| f.path == fp) {
f.active = r.enables_expansion();
}
});
}
// Wire preset change
{
let wfs = watch_state.clone();
let fp = folder_path.clone();
let pnames = preset_names.to_vec();
preset_row.connect_selected_notify(move |r| {
let mut folders = wfs.borrow_mut();
if let Some(f) = folders.iter_mut().find(|f| f.path == fp) {
f.preset_name = pnames.get(r.selected() as usize)
.cloned()
.unwrap_or_default();
}
});
}
// Wire recursive toggle
{
let wfs = watch_state.clone();
let fp = folder_path.clone();
recursive_row.connect_active_notify(move |r| {
let mut folders = wfs.borrow_mut();
if let Some(f) = folders.iter_mut().find(|f| f.path == fp) {
f.recursive = r.is_active();
}
});
}
// Wire remove button
{
let wfs = watch_state.clone();
let lb = list_box.clone();
let fp = folder_path;
let r = row.clone();
remove_btn.connect_clicked(move |_| {
wfs.borrow_mut().retain(|f| f.path != fp);
lb.remove(&r);
});
}
row
}