From b40b15c3a573d5669031f019ac06766f88e3e67e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 13:45:00 +0000 Subject: [PATCH] Fix all issues from code/UX review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ISO structural: - Move post-install.sh → airootfs/etc/calamares/ (it was never in the squashfs) - Create airootfs/etc/skel/.config/ with all dotfiles (deploy path now works) - Add iso/pacman.conf with [breadway] custom repo stub for calamares + bakery - Add Calamares branding component (bos/branding.desc + show.qml) - Add missing unpackfs.conf and mount.conf modules - Add live-session autostart: getty autologin → bash_profile → Hyprland → calamares - Add polkit rule for wheel-group snapper rollback (pkexec path) - Remove wlroots from packages (bundled with Hyprland); add bakery to package list - Fix modules-search path in settings.conf Dotfiles: - Rename dotfiles/hyprland/ → dotfiles/hypr/ (Hyprland reads ~/.config/hypr/) - Fix deprecated shadow options: drop_shadow/shadow_range → shadow { } block bos-settings Rust: - Replace glib::MainContext::channel (removed in glib 0.19) with async_channel - Stream bakery update output line-by-line instead of buffering all at once - Fix zombie processes: per-package update buttons now wait() in a thread - Fix sidebar/stack mismatch at startup: select snapshots row to match default view - Replace deprecated MessageDialog with AlertDialog (GTK 4.10+) throughout - Use pkexec for snapper rollback so polkit handles privilege escalation - Add confirmation dialog before delete snapshot (was missing, rollback had one) - Add refresh button + repopulate after delete in snapshots view - Add "Saved" / "Error: …" status label to every config view save button - Add "Remove" buttons to breadbox contexts and breadcrumbs profiles - Remove hardcoded model string from breadpad defaults - Drop unused state mod; fix config_dir HOME fallback; fix zombie in editor launches https://claude.ai/code/session_01WszGHvCmxgcyTwNSkfLF9P --- Cargo.toml | 1 + src/config/mod.rs | 16 +-- src/main.rs | 1 - src/ui/sidebar.rs | 30 +++--- src/ui/views/bread.rs | 96 +++++++++-------- src/ui/views/breadbar.rs | 28 ++++- src/ui/views/breadbox.rs | 38 ++++++- src/ui/views/breadcrumbs.rs | 38 ++++++- src/ui/views/breadpad.rs | 56 +++++----- src/ui/views/hyprland.rs | 30 +++--- src/ui/views/packages.rs | 132 +++++++++++++---------- src/ui/views/snapshots.rs | 209 ++++++++++++++++++++---------------- 12 files changed, 400 insertions(+), 275 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 24dabe0..d906354 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,3 +9,4 @@ glib = "0.20" serde = { version = "1", features = ["derive"] } serde_json = "1" toml = "0.8" +async-channel = "2" diff --git a/src/config/mod.rs b/src/config/mod.rs index 08b005b..da3b8eb 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,5 +1,5 @@ use std::error::Error; -use std::path::Path; +use std::path::{Path, PathBuf}; pub fn load serde::Deserialize<'de>>(path: &Path) -> Result> { let text = std::fs::read_to_string(path)?; @@ -14,11 +14,11 @@ pub fn save(path: &Path, val: &T) -> Result<(), Box std::path::PathBuf { - dirs_path() -} - -fn dirs_path() -> std::path::PathBuf { - let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string()); - std::path::PathBuf::from(home).join(".config") +pub fn config_dir() -> PathBuf { + let home = std::env::var("HOME").unwrap_or_else(|_| { + std::env::var("XDG_CONFIG_HOME") + .map(|p| PathBuf::from(p).parent().unwrap_or(Path::new("/")).to_string_lossy().to_string()) + .unwrap_or_else(|_| "/home/user".to_string()) + }); + PathBuf::from(home).join(".config") } diff --git a/src/main.rs b/src/main.rs index c93a8d2..fc13dc2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,4 @@ mod config; -mod state; mod theme; mod ui; diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs index daafb0b..4395591 100644 --- a/src/ui/sidebar.rs +++ b/src/ui/sidebar.rs @@ -1,5 +1,5 @@ use gtk4::prelude::*; -use gtk4::{Box as GBox, Label, ListBox, ListBoxRow, Orientation, Separator}; +use gtk4::{Box as GBox, Label, ListBox, ListBoxRow, Orientation}; pub struct SidebarItem { pub id: &'static str, @@ -7,17 +7,17 @@ pub struct SidebarItem { } pub const APPS_ITEMS: &[SidebarItem] = &[ - SidebarItem { id: "bread", label: "bread" }, - SidebarItem { id: "breadbar", label: "breadbar" }, - SidebarItem { id: "breadbox", label: "breadbox" }, + SidebarItem { id: "bread", label: "bread" }, + SidebarItem { id: "breadbar", label: "breadbar" }, + SidebarItem { id: "breadbox", label: "breadbox" }, SidebarItem { id: "breadcrumbs", label: "breadcrumbs" }, - SidebarItem { id: "breadpad", label: "breadpad" }, + SidebarItem { id: "breadpad", label: "breadpad" }, ]; pub const SYSTEM_ITEMS: &[SidebarItem] = &[ SidebarItem { id: "snapshots", label: "Snapshots" }, - SidebarItem { id: "packages", label: "Packages" }, - SidebarItem { id: "hyprland", label: "Hyprland" }, + SidebarItem { id: "packages", label: "Packages" }, + SidebarItem { id: "hyprland", label: "Hyprland" }, ]; pub fn build() -> (GBox, ListBox) { @@ -32,9 +32,17 @@ pub fn build() -> (GBox, ListBox) { append_section(&list, "Apps", APPS_ITEMS); append_section(&list, "System", SYSTEM_ITEMS); - // Select first item by default - if let Some(first) = list.row_at_index(1) { - list.select_row(Some(&first)); + // Select the snapshots row so it matches the default stack page + let mut i = 0; + loop { + match list.row_at_index(i) { + None => break, + Some(row) if row.widget_name() == "snapshots" => { + list.select_row(Some(&row)); + break; + } + _ => i += 1, + } } vbox.append(&list); @@ -42,7 +50,6 @@ pub fn build() -> (GBox, ListBox) { } fn append_section(list: &ListBox, title: &str, items: &[SidebarItem]) { - // Section header (non-selectable) let header_row = ListBoxRow::new(); header_row.set_selectable(false); header_row.set_activatable(false); @@ -55,7 +62,6 @@ fn append_section(list: &ListBox, title: &str, items: &[SidebarItem]) { for item in items { let row = ListBoxRow::new(); row.set_widget_name(item.id); - let lbl = Label::new(Some(item.label)); lbl.set_xalign(0.0); lbl.set_margin_top(2); diff --git a/src/ui/views/bread.rs b/src/ui/views/bread.rs index ca4b080..27d0596 100644 --- a/src/ui/views/bread.rs +++ b/src/ui/views/bread.rs @@ -14,34 +14,22 @@ pub struct BreadConfig { pub adapters: AdaptersConfig, } -fn default_log_level() -> String { - "info".to_string() -} +fn default_log_level() -> String { "info".to_string() } #[derive(Deserialize, Serialize, Clone, Default)] pub struct AdaptersConfig { - #[serde(default = "default_true")] - pub keyboard: bool, - #[serde(default = "default_true")] - pub mouse: bool, - #[serde(default = "default_true")] - pub touchpad: bool, - #[serde(default = "default_true")] - pub bluetooth: bool, - #[serde(default = "default_true")] - pub gamepad: bool, + #[serde(default = "default_true")] pub keyboard: bool, + #[serde(default = "default_true")] pub mouse: bool, + #[serde(default = "default_true")] pub touchpad: bool, + #[serde(default = "default_true")] pub bluetooth: bool, + #[serde(default = "default_true")] pub gamepad: bool, } -fn default_true() -> bool { - true -} +fn default_true() -> bool { true } impl Default for BreadConfig { fn default() -> Self { - Self { - log_level: default_log_level(), - adapters: AdaptersConfig::default(), - } + Self { log_level: default_log_level(), adapters: AdaptersConfig::default() } } } @@ -49,7 +37,12 @@ fn config_path() -> std::path::PathBuf { config::config_dir().join("bread/breadd.toml") } -fn adapter_row(label: &str, active: bool, cfg: Rc>, field: &'static str) -> GBox { +fn adapter_row( + label: &str, + active: bool, + cfg: Rc>, + field: &'static str, +) -> GBox { let row = GBox::new(Orientation::Horizontal, 16); let lbl = Label::new(Some(label)); lbl.set_hexpand(true); @@ -60,11 +53,11 @@ fn adapter_row(label: &str, active: bool, cfg: Rc>, field: let val = s.is_active(); let mut c = cfg.borrow_mut(); match field { - "keyboard" => c.adapters.keyboard = val, - "mouse" => c.adapters.mouse = val, - "touchpad" => c.adapters.touchpad = val, - "bluetooth" => c.adapters.bluetooth = val, - "gamepad" => c.adapters.gamepad = val, + "keyboard" => c.adapters.keyboard = val, + "mouse" => c.adapters.mouse = val, + "touchpad" => c.adapters.touchpad = val, + "bluetooth" => c.adapters.bluetooth = val, + "gamepad" => c.adapters.gamepad = val, _ => {} } }); @@ -94,15 +87,10 @@ pub fn build() -> GBox { lbl.set_xalign(0.0); let levels = StringList::new(&["error", "warn", "info", "debug", "trace"]); let dropdown = DropDown::new(Some(levels), gtk4::Expression::NONE); - let current_pos = match cfg.borrow().log_level.as_str() { - "error" => 0u32, - "warn" => 1, - "info" => 2, - "debug" => 3, - "trace" => 4, - _ => 2, + let pos = match cfg.borrow().log_level.as_str() { + "error" => 0u32, "warn" => 1, "info" => 2, "debug" => 3, "trace" => 4, _ => 2, }; - dropdown.set_selected(current_pos); + dropdown.set_selected(pos); { let cfg = cfg.clone(); dropdown.connect_selected_notify(move |dd| { @@ -116,7 +104,6 @@ pub fn build() -> GBox { row.append(&dropdown); vbox.append(&row); - // Adapter toggles let adapter_label = Label::new(Some("Adapters")); adapter_label.set_xalign(0.0); adapter_label.set_margin_top(8); @@ -125,25 +112,44 @@ pub fn build() -> GBox { let (kbd, mouse, touchpad, bluetooth, gamepad) = { let c = cfg.borrow(); - (c.adapters.keyboard, c.adapters.mouse, c.adapters.touchpad, c.adapters.bluetooth, c.adapters.gamepad) + (c.adapters.keyboard, c.adapters.mouse, c.adapters.touchpad, + c.adapters.bluetooth, c.adapters.gamepad) }; - - vbox.append(&adapter_row("Keyboard", kbd, cfg.clone(), "keyboard")); - vbox.append(&adapter_row("Mouse", mouse, cfg.clone(), "mouse")); - vbox.append(&adapter_row("Touchpad", touchpad, cfg.clone(), "touchpad")); + vbox.append(&adapter_row("Keyboard", kbd, cfg.clone(), "keyboard")); + vbox.append(&adapter_row("Mouse", mouse, cfg.clone(), "mouse")); + vbox.append(&adapter_row("Touchpad", touchpad, cfg.clone(), "touchpad")); vbox.append(&adapter_row("Bluetooth", bluetooth, cfg.clone(), "bluetooth")); - vbox.append(&adapter_row("Gamepad", gamepad, cfg.clone(), "gamepad")); + vbox.append(&adapter_row("Gamepad", gamepad, cfg.clone(), "gamepad")); + + let btn_row = GBox::new(Orientation::Horizontal, 12); + btn_row.set_margin_top(16); let save_btn = Button::with_label("Save"); - save_btn.set_margin_top(16); - save_btn.set_halign(gtk4::Align::Start); + let status_lbl = Label::new(None); + status_lbl.add_css_class("dim-label"); + { let cfg = cfg.clone(); + let path = path.clone(); + let status_lbl = status_lbl.clone(); save_btn.connect_clicked(move |_| { - let _ = config::save(&path, &*cfg.borrow()); + match config::save(&path, &*cfg.borrow()) { + Ok(()) => { + status_lbl.set_text("Saved"); + let lbl = status_lbl.clone(); + glib::timeout_add_seconds_local(3, move || { + lbl.set_text(""); + glib::ControlFlow::Break + }); + } + Err(e) => status_lbl.set_text(&format!("Error: {e}")), + } }); } - vbox.append(&save_btn); + + btn_row.append(&save_btn); + btn_row.append(&status_lbl); + vbox.append(&btn_row); vbox } diff --git a/src/ui/views/breadbar.rs b/src/ui/views/breadbar.rs index 2314111..9f1ceb7 100644 --- a/src/ui/views/breadbar.rs +++ b/src/ui/views/breadbar.rs @@ -3,7 +3,7 @@ use gtk4::{Box as GBox, Button, Label, Orientation, ScrolledWindow, TextView}; use std::path::PathBuf; fn css_path() -> PathBuf { - let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string()); + let home = std::env::var("HOME").unwrap_or_else(|_| "/home/user".to_string()); PathBuf::from(home).join(".config/breadbar/style.css") } @@ -38,21 +38,39 @@ pub fn build() -> GBox { scroll.set_child(Some(&text_view)); vbox.append(&scroll); + let btn_row = GBox::new(Orientation::Horizontal, 12); + btn_row.set_margin_top(12); + let save_btn = Button::with_label("Save"); - save_btn.set_margin_top(12); - save_btn.set_halign(gtk4::Align::Start); + let status_lbl = Label::new(None); + status_lbl.add_css_class("dim-label"); + { let path = path.clone(); + let status_lbl = status_lbl.clone(); save_btn.connect_clicked(move |_| { let (start, end) = buf.bounds(); let text = buf.text(&start, &end, false); if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } - let _ = std::fs::write(&path, text.as_str()); + match std::fs::write(&path, text.as_str()) { + Ok(()) => { + status_lbl.set_text("Saved"); + let lbl = status_lbl.clone(); + glib::timeout_add_seconds_local(3, move || { + lbl.set_text(""); + glib::ControlFlow::Break + }); + } + Err(e) => status_lbl.set_text(&format!("Error: {e}")), + } }); } - vbox.append(&save_btn); + + btn_row.append(&save_btn); + btn_row.append(&status_lbl); + vbox.append(&btn_row); vbox } diff --git a/src/ui/views/breadbox.rs b/src/ui/views/breadbox.rs index 715ce71..36f88ac 100644 --- a/src/ui/views/breadbox.rs +++ b/src/ui/views/breadbox.rs @@ -29,6 +29,8 @@ fn rebuild_list(list: &ListBox, cfg: &Rc>) { } for (i, ctx) in cfg.borrow().context.iter().enumerate() { let row = ListBoxRow::new(); + row.set_selectable(false); + let hbox = GBox::new(Orientation::Horizontal, 8); hbox.set_margin_top(6); hbox.set_margin_bottom(6); @@ -37,13 +39,17 @@ fn rebuild_list(list: &ListBox, cfg: &Rc>) { let name_entry = Entry::new(); name_entry.set_text(&ctx.name); - name_entry.set_width_chars(16); + name_entry.set_width_chars(14); + name_entry.set_placeholder_text(Some("name")); let apps_entry = Entry::new(); apps_entry.set_text(&ctx.apps.join(", ")); apps_entry.set_hexpand(true); apps_entry.set_placeholder_text(Some("app1, app2, ...")); + let remove_btn = Button::with_label("Remove"); + remove_btn.add_css_class("destructive-action"); + { let cfg = cfg.clone(); name_entry.connect_changed(move |e| { @@ -56,8 +62,7 @@ fn rebuild_list(list: &ListBox, cfg: &Rc>) { let cfg = cfg.clone(); apps_entry.connect_changed(move |e| { if let Some(c) = cfg.borrow_mut().context.get_mut(i) { - c.apps = e - .text() + c.apps = e.text() .split(',') .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) @@ -65,9 +70,18 @@ fn rebuild_list(list: &ListBox, cfg: &Rc>) { } }); } + { + let cfg = cfg.clone(); + let list = list.clone(); + remove_btn.connect_clicked(move |_| { + cfg.borrow_mut().context.remove(i); + rebuild_list(&list, &cfg); + }); + } hbox.append(&name_entry); hbox.append(&apps_entry); + hbox.append(&remove_btn); row.set_child(Some(&hbox)); list.append(&row); } @@ -93,7 +107,6 @@ pub fn build() -> GBox { let list = ListBox::new(); list.set_selection_mode(gtk4::SelectionMode::None); - rebuild_list(&list, &cfg); let scroll = ScrolledWindow::new(); @@ -118,16 +131,31 @@ pub fn build() -> GBox { } let save_btn = Button::with_label("Save"); + let status_lbl = Label::new(None); + status_lbl.add_css_class("dim-label"); + { let cfg = cfg.clone(); let path = path.clone(); + let status_lbl = status_lbl.clone(); save_btn.connect_clicked(move |_| { - let _ = config::save(&path, &*cfg.borrow()); + match config::save(&path, &*cfg.borrow()) { + Ok(()) => { + status_lbl.set_text("Saved"); + let lbl = status_lbl.clone(); + glib::timeout_add_seconds_local(3, move || { + lbl.set_text(""); + glib::ControlFlow::Break + }); + } + Err(e) => status_lbl.set_text(&format!("Error: {e}")), + } }); } btn_row.append(&add_btn); btn_row.append(&save_btn); + btn_row.append(&status_lbl); vbox.append(&btn_row); vbox diff --git a/src/ui/views/breadcrumbs.rs b/src/ui/views/breadcrumbs.rs index 4062645..f165f43 100644 --- a/src/ui/views/breadcrumbs.rs +++ b/src/ui/views/breadcrumbs.rs @@ -29,6 +29,8 @@ fn rebuild_list(list: &ListBox, cfg: &Rc>) { } for (i, profile) in cfg.borrow().profile.iter().enumerate() { let row = ListBoxRow::new(); + row.set_selectable(false); + let hbox = GBox::new(Orientation::Horizontal, 8); hbox.set_margin_top(6); hbox.set_margin_bottom(6); @@ -37,13 +39,17 @@ fn rebuild_list(list: &ListBox, cfg: &Rc>) { let name_entry = Entry::new(); name_entry.set_text(&profile.name); - name_entry.set_width_chars(16); + name_entry.set_width_chars(14); + name_entry.set_placeholder_text(Some("name")); let ssids_entry = Entry::new(); ssids_entry.set_text(&profile.ssids.join(", ")); ssids_entry.set_hexpand(true); ssids_entry.set_placeholder_text(Some("SSID1, SSID2, ...")); + let remove_btn = Button::with_label("Remove"); + remove_btn.add_css_class("destructive-action"); + { let cfg = cfg.clone(); name_entry.connect_changed(move |e| { @@ -56,8 +62,7 @@ fn rebuild_list(list: &ListBox, cfg: &Rc>) { let cfg = cfg.clone(); ssids_entry.connect_changed(move |e| { if let Some(p) = cfg.borrow_mut().profile.get_mut(i) { - p.ssids = e - .text() + p.ssids = e.text() .split(',') .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()) @@ -65,9 +70,18 @@ fn rebuild_list(list: &ListBox, cfg: &Rc>) { } }); } + { + let cfg = cfg.clone(); + let list = list.clone(); + remove_btn.connect_clicked(move |_| { + cfg.borrow_mut().profile.remove(i); + rebuild_list(&list, &cfg); + }); + } hbox.append(&name_entry); hbox.append(&ssids_entry); + hbox.append(&remove_btn); row.set_child(Some(&hbox)); list.append(&row); } @@ -93,7 +107,6 @@ pub fn build() -> GBox { let list = ListBox::new(); list.set_selection_mode(gtk4::SelectionMode::None); - rebuild_list(&list, &cfg); let scroll = ScrolledWindow::new(); @@ -118,16 +131,31 @@ pub fn build() -> GBox { } let save_btn = Button::with_label("Save"); + let status_lbl = Label::new(None); + status_lbl.add_css_class("dim-label"); + { let cfg = cfg.clone(); let path = path.clone(); + let status_lbl = status_lbl.clone(); save_btn.connect_clicked(move |_| { - let _ = config::save(&path, &*cfg.borrow()); + match config::save(&path, &*cfg.borrow()) { + Ok(()) => { + status_lbl.set_text("Saved"); + let lbl = status_lbl.clone(); + glib::timeout_add_seconds_local(3, move || { + lbl.set_text(""); + glib::ControlFlow::Break + }); + } + Err(e) => status_lbl.set_text(&format!("Error: {e}")), + } }); } btn_row.append(&add_btn); btn_row.append(&save_btn); + btn_row.append(&status_lbl); vbox.append(&btn_row); vbox diff --git a/src/ui/views/breadpad.rs b/src/ui/views/breadpad.rs index 9c553f4..6e24346 100644 --- a/src/ui/views/breadpad.rs +++ b/src/ui/views/breadpad.rs @@ -8,7 +8,7 @@ use crate::config; #[derive(Deserialize, Serialize, Clone)] pub struct BreadpadConfig { - #[serde(default = "default_model")] + #[serde(default)] pub model: String, #[serde(default = "default_true")] pub reminders: bool, @@ -16,21 +16,11 @@ pub struct BreadpadConfig { pub calendar: bool, } -fn default_model() -> String { - "claude-sonnet-4-6".to_string() -} - -fn default_true() -> bool { - true -} +fn default_true() -> bool { true } impl Default for BreadpadConfig { fn default() -> Self { - Self { - model: default_model(), - reminders: true, - calendar: true, - } + Self { model: String::new(), reminders: true, calendar: true } } } @@ -58,6 +48,7 @@ pub fn build() -> GBox { lbl.set_xalign(0.0); let model_entry = Entry::new(); model_entry.set_text(&cfg.borrow().model); + model_entry.set_placeholder_text(Some("e.g. claude-sonnet-4-6")); { let cfg = cfg.clone(); model_entry.connect_changed(move |e| { @@ -68,7 +59,7 @@ pub fn build() -> GBox { row.append(&model_entry); vbox.append(&row); - // Reminders toggle + // Reminders let row = GBox::new(Orientation::Horizontal, 16); let lbl = Label::new(Some("Reminders")); lbl.set_hexpand(true); @@ -77,15 +68,13 @@ pub fn build() -> GBox { sw.set_active(cfg.borrow().reminders); { let cfg = cfg.clone(); - sw.connect_active_notify(move |s| { - cfg.borrow_mut().reminders = s.is_active(); - }); + sw.connect_active_notify(move |s| { cfg.borrow_mut().reminders = s.is_active(); }); } row.append(&lbl); row.append(&sw); vbox.append(&row); - // Calendar toggle + // Calendar let row = GBox::new(Orientation::Horizontal, 16); let lbl = Label::new(Some("Calendar integration")); lbl.set_hexpand(true); @@ -94,25 +83,40 @@ pub fn build() -> GBox { sw.set_active(cfg.borrow().calendar); { let cfg = cfg.clone(); - sw.connect_active_notify(move |s| { - cfg.borrow_mut().calendar = s.is_active(); - }); + sw.connect_active_notify(move |s| { cfg.borrow_mut().calendar = s.is_active(); }); } row.append(&lbl); row.append(&sw); vbox.append(&row); + let btn_row = GBox::new(Orientation::Horizontal, 12); + btn_row.set_margin_top(16); + let save_btn = Button::with_label("Save"); - save_btn.set_margin_top(16); - save_btn.set_halign(gtk4::Align::Start); + let status_lbl = Label::new(None); + status_lbl.add_css_class("dim-label"); + { let cfg = cfg.clone(); - let path = path.clone(); + let status_lbl = status_lbl.clone(); save_btn.connect_clicked(move |_| { - let _ = config::save(&path, &*cfg.borrow()); + match config::save(&path, &*cfg.borrow()) { + Ok(()) => { + status_lbl.set_text("Saved"); + let lbl = status_lbl.clone(); + glib::timeout_add_seconds_local(3, move || { + lbl.set_text(""); + glib::ControlFlow::Break + }); + } + Err(e) => status_lbl.set_text(&format!("Error: {e}")), + } }); } - vbox.append(&save_btn); + + btn_row.append(&save_btn); + btn_row.append(&status_lbl); + vbox.append(&btn_row); vbox } diff --git a/src/ui/views/hyprland.rs b/src/ui/views/hyprland.rs index cb43b30..0ed704d 100644 --- a/src/ui/views/hyprland.rs +++ b/src/ui/views/hyprland.rs @@ -17,14 +17,14 @@ fn get_monitors() -> Vec { let w = m.get("width")?.as_u64()?; let h = m.get("height")?.as_u64()?; let refresh = m.get("refreshRate")?.as_f64()?; - Some(format!("{name} {w}×{h} @ {refresh:.0}Hz")) + Some(format!("{name} {w}x{h} @ {refresh:.0}Hz")) }) .collect() } -fn config_path() -> std::path::PathBuf { - let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string()); - std::path::PathBuf::from(home).join(".config/hypr/hyprland.conf") +fn hypr_path(name: &str) -> std::path::PathBuf { + let home = std::env::var("HOME").unwrap_or_else(|_| "/home/user".to_string()); + std::path::PathBuf::from(home).join(".config/hypr").join(name) } pub fn build() -> GBox { @@ -36,7 +36,6 @@ pub fn build() -> GBox { title.set_xalign(0.0); vbox.append(&title); - // Monitors section let monitors_lbl = Label::new(Some("Connected monitors")); monitors_lbl.set_xalign(0.0); monitors_lbl.set_margin_top(8); @@ -52,38 +51,35 @@ pub fn build() -> GBox { for mon in &monitors { let lbl = Label::new(Some(mon)); lbl.set_xalign(0.0); - lbl.add_css_class("monospace"); + lbl.set_monospace(true); vbox.append(&lbl); } } - // Open config button let open_btn = Button::with_label("Open hyprland.conf in editor"); open_btn.set_margin_top(16); open_btn.set_halign(gtk4::Align::Start); { - let path = config_path(); + let conf_path = hypr_path("hyprland.conf"); open_btn.connect_clicked(move |_| { let editor = std::env::var("EDITOR").unwrap_or_else(|_| "foot".to_string()); - let _ = Command::new(&editor) - .arg(path.to_str().unwrap_or("")) - .spawn(); + if let Ok(mut child) = Command::new(&editor).arg(&conf_path).spawn() { + std::thread::spawn(move || { let _ = child.wait(); }); + } }); } vbox.append(&open_btn); - // Open keybinds button let keybinds_btn = Button::with_label("Open keybinds.conf in editor"); keybinds_btn.set_margin_top(8); keybinds_btn.set_halign(gtk4::Align::Start); { - let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string()); - let kb_path = std::path::PathBuf::from(home).join(".config/hypr/keybinds.conf"); + let kb_path = hypr_path("keybinds.conf"); keybinds_btn.connect_clicked(move |_| { let editor = std::env::var("EDITOR").unwrap_or_else(|_| "foot".to_string()); - let _ = Command::new(&editor) - .arg(kb_path.to_str().unwrap_or("")) - .spawn(); + if let Ok(mut child) = Command::new(&editor).arg(&kb_path).spawn() { + std::thread::spawn(move || { let _ = child.wait(); }); + } }); } vbox.append(&keybinds_btn); diff --git a/src/ui/views/packages.rs b/src/ui/views/packages.rs index 448ec53..6b3aedc 100644 --- a/src/ui/views/packages.rs +++ b/src/ui/views/packages.rs @@ -1,31 +1,20 @@ +use async_channel; use gtk4::prelude::*; use gtk4::{ Box as GBox, Button, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, TextView, }; -use serde::Deserialize; use std::collections::HashMap; -use std::process::Command; - -#[derive(Deserialize, Default)] -struct InstalledPackages { - #[serde(flatten)] - packages: HashMap, -} - -#[derive(Deserialize)] -struct PackageInfo { - version: String, -} +use std::io::{BufRead, BufReader}; +use std::process::{Command, Stdio}; fn read_installed() -> HashMap { - let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string()); + let home = std::env::var("HOME").unwrap_or_else(|_| "/home/user".to_string()); let path = std::path::Path::new(&home) .join(".local/state/bakery/installed.json"); let Ok(text) = std::fs::read_to_string(&path) else { return HashMap::new(); }; - let Ok(parsed) = serde_json::from_str::>(&text) else { return HashMap::new(); }; @@ -35,15 +24,57 @@ fn read_installed() -> HashMap { .filter_map(|(name, val)| { let version = val .get("version") - .or_else(|| val.as_str().map(|_| &val)) .and_then(|v| v.as_str()) - .map(|s| s.to_string()) - .unwrap_or_else(|| "unknown".to_string()); + .unwrap_or("unknown") + .to_string(); Some((name, version)) }) .collect() } +fn stream_command(args: &[&str], log_buf: gtk4::TextBuffer) { + let (sender, receiver) = async_channel::bounded::(256); + let args: Vec = args.iter().map(|s| s.to_string()).collect(); + + std::thread::spawn(move || { + let mut child = match Command::new(&args[0]) + .args(&args[1..]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + { + Ok(c) => c, + Err(e) => { + let _ = sender.send_blocking(format!("Error: {e}")); + return; + } + }; + + // Merge stderr into the channel too + let stdout = child.stdout.take().unwrap(); + let stderr = child.stderr.take().unwrap(); + + let tx2 = sender.clone(); + std::thread::spawn(move || { + for line in BufReader::new(stderr).lines().flatten() { + let _ = tx2.send_blocking(line); + } + }); + + for line in BufReader::new(stdout).lines().flatten() { + let _ = sender.send_blocking(line); + } + let _ = child.wait(); + }); + + glib::spawn_future_local(async move { + while let Ok(line) = receiver.recv().await { + let mut end = log_buf.end_iter(); + log_buf.insert(&mut end, &format!("{line}\n")); + } + }); +} + pub fn build() -> GBox { let vbox = GBox::new(Orientation::Vertical, 0); vbox.add_css_class("view-content"); @@ -64,7 +95,10 @@ pub fn build() -> GBox { let packages = read_installed(); if packages.is_empty() { let row = ListBoxRow::new(); - let lbl = Label::new(Some("No bakery packages found (~/.local/state/bakery/installed.json)")); + row.set_selectable(false); + let lbl = Label::new(Some( + "No bakery packages found (~/.local/state/bakery/installed.json)", + )); lbl.set_margin_top(8); lbl.set_margin_bottom(8); lbl.set_margin_start(8); @@ -73,8 +107,10 @@ pub fn build() -> GBox { } else { let mut names: Vec<_> = packages.iter().collect(); names.sort_by_key(|(k, _)| k.as_str()); + for (name, version) in names { let row = ListBoxRow::new(); + row.set_selectable(false); let hbox = GBox::new(Orientation::Horizontal, 16); hbox.set_margin_top(6); hbox.set_margin_bottom(6); @@ -88,12 +124,16 @@ pub fn build() -> GBox { let ver_lbl = Label::new(Some(version)); ver_lbl.set_xalign(1.0); - let update_btn = Button::with_label("Update"); + // Spawn a thread to reap the child process — no zombies let pkg_name = name.clone(); + let update_btn = Button::with_label("Update"); update_btn.connect_clicked(move |_| { - let _ = Command::new("bakery") - .args(["update", &pkg_name]) - .spawn(); + match Command::new("bakery").args(["update", &pkg_name]).spawn() { + Ok(mut child) => { + std::thread::spawn(move || { let _ = child.wait(); }); + } + Err(e) => eprintln!("bakery update failed: {e}"), + } }); hbox.append(&name_lbl); @@ -109,56 +149,32 @@ pub fn build() -> GBox { scroll.set_child(Some(&list)); vbox.append(&scroll); + let log_buf = gtk4::TextBuffer::new(None); + let log_view = TextView::with_buffer(&log_buf); + log_view.set_editable(false); + log_view.set_monospace(true); + log_view.set_height_request(140); + log_view.set_margin_top(8); + let btn_row = GBox::new(Orientation::Horizontal, 8); btn_row.set_margin_top(12); let check_btn = Button::with_label("Check for updates"); let update_all_btn = Button::with_label("Update all"); - let log_buf = gtk4::TextBuffer::new(None); - let log_view = TextView::with_buffer(&log_buf); - log_view.set_editable(false); - log_view.set_height_request(120); - log_view.set_margin_top(8); - { let log_buf = log_buf.clone(); check_btn.connect_clicked(move |_| { - log_buf.set_text("Checking for updates...\n"); - match Command::new("bakery").args(["list"]).output() { - Ok(out) => { - let text = String::from_utf8_lossy(&out.stdout); - log_buf.set_text(&format!("{text}\n")); - } - Err(e) => { - log_buf.set_text(&format!("Error: {e}\n")); - } - } + log_buf.set_text(""); + stream_command(&["bakery", "list"], log_buf.clone()); }); } { let log_buf = log_buf.clone(); update_all_btn.connect_clicked(move |_| { - log_buf.set_text("Running bakery update --all...\n"); - let (sender, receiver) = glib::MainContext::channel(glib::Priority::DEFAULT); - std::thread::spawn(move || { - let result = Command::new("bakery") - .args(["update", "--all"]) - .output(); - match result { - Ok(out) => { - let _ = sender.send(String::from_utf8_lossy(&out.stdout).to_string()); - } - Err(e) => { - let _ = sender.send(format!("Error: {e}\n")); - } - } - }); - receiver.attach(None, move |msg| { - log_buf.set_text(&msg); - glib::ControlFlow::Break - }); + log_buf.set_text(""); + stream_command(&["bakery", "update", "--all"], log_buf.clone()); }); } diff --git a/src/ui/views/snapshots.rs b/src/ui/views/snapshots.rs index da34141..d3ac8b7 100644 --- a/src/ui/views/snapshots.rs +++ b/src/ui/views/snapshots.rs @@ -1,9 +1,10 @@ use gtk4::prelude::*; use gtk4::{ - Box as GBox, Button, Label, ListBox, ListBoxRow, MessageDialog, Orientation, ScrolledWindow, + AlertDialog, Box as GBox, Button, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, }; use std::process::Command; +#[derive(Clone)] struct SnapshotRow { number: String, date: String, @@ -22,47 +23,60 @@ fn list_snapshots() -> Vec { text.lines() .skip(2) // header + separator .filter_map(|line| { - let cols: Vec<&str> = line.splitn(3, '|').collect(); - if cols.len() == 3 { - Some(SnapshotRow { - number: cols[0].trim().to_string(), - date: cols[1].trim().to_string(), - description: cols[2].trim().to_string(), - }) - } else { - None - } + let mut cols = line.splitn(3, '|'); + Some(SnapshotRow { + number: cols.next()?.trim().to_string(), + date: cols.next()?.trim().to_string(), + description: cols.next()?.trim().to_string(), + }) }) .collect() } -fn confirm_rollback(number: &str) { - let number = number.to_string(); - let dialog = MessageDialog::new( - None::<>k4::Window>, - gtk4::DialogFlags::MODAL, - gtk4::MessageType::Question, - gtk4::ButtonsType::OkCancel, - &format!("Roll back to snapshot #{number}?\n\nReboot required to apply."), - ); - dialog.connect_response(move |d, resp| { - if resp == gtk4::ResponseType::Ok { - let _ = Command::new("snapper") - .args(["rollback", &number]) - .status(); - let info = MessageDialog::new( - None::<>k4::Window>, - gtk4::DialogFlags::MODAL, - gtk4::MessageType::Info, - gtk4::ButtonsType::Ok, - "Rollback queued. Please reboot to apply.", - ); - info.connect_response(|d, _| d.destroy()); - info.present(); - } - d.destroy(); - }); - dialog.present(); +fn populate_list(list: &ListBox) { + while let Some(child) = list.first_child() { + list.remove(&child); + } + let snapshots = list_snapshots(); + if snapshots.is_empty() { + let row = ListBoxRow::new(); + row.set_selectable(false); + let lbl = Label::new(Some("No snapshots found (snapper may not be configured yet)")); + lbl.set_margin_top(8); + lbl.set_margin_bottom(8); + lbl.set_margin_start(8); + row.set_child(Some(&lbl)); + list.append(&row); + return; + } + for snap in &snapshots { + let row = ListBoxRow::new(); + row.set_widget_name(&snap.number); + + let hbox = GBox::new(Orientation::Horizontal, 16); + hbox.set_margin_top(6); + hbox.set_margin_bottom(6); + hbox.set_margin_start(8); + hbox.set_margin_end(8); + + let num_lbl = Label::new(Some(&snap.number)); + num_lbl.set_width_chars(4); + num_lbl.set_xalign(0.0); + + let date_lbl = Label::new(Some(&snap.date)); + date_lbl.set_width_chars(22); + date_lbl.set_xalign(0.0); + + let desc_lbl = Label::new(Some(&snap.description)); + desc_lbl.set_hexpand(true); + desc_lbl.set_xalign(0.0); + + hbox.append(&num_lbl); + hbox.append(&date_lbl); + hbox.append(&desc_lbl); + row.set_child(Some(&hbox)); + list.append(&row); + } } pub fn build() -> GBox { @@ -74,56 +88,16 @@ pub fn build() -> GBox { title.set_xalign(0.0); vbox.append(&title); - let subtitle = - Label::new(Some("System snapshots created by snap-pac on each pacman transaction.")); + let subtitle = Label::new(Some( + "System snapshots created by snap-pac on each pacman transaction.", + )); subtitle.set_xalign(0.0); subtitle.set_margin_bottom(16); vbox.append(&subtitle); let list = ListBox::new(); list.set_selection_mode(gtk4::SelectionMode::Single); - - let snapshots = list_snapshots(); - if snapshots.is_empty() { - let row = ListBoxRow::new(); - let lbl = Label::new(Some( - "No snapshots found (snapper may not be configured yet)", - )); - lbl.set_margin_top(8); - lbl.set_margin_bottom(8); - lbl.set_margin_start(8); - row.set_child(Some(&lbl)); - list.append(&row); - } else { - for snap in &snapshots { - let row = ListBoxRow::new(); - row.set_widget_name(&snap.number); - - let hbox = GBox::new(Orientation::Horizontal, 16); - hbox.set_margin_top(6); - hbox.set_margin_bottom(6); - hbox.set_margin_start(8); - hbox.set_margin_end(8); - - let num_lbl = Label::new(Some(&snap.number)); - num_lbl.set_width_chars(4); - num_lbl.set_xalign(0.0); - - let date_lbl = Label::new(Some(&snap.date)); - date_lbl.set_width_chars(22); - date_lbl.set_xalign(0.0); - - let desc_lbl = Label::new(Some(&snap.description)); - desc_lbl.set_hexpand(true); - desc_lbl.set_xalign(0.0); - - hbox.append(&num_lbl); - hbox.append(&date_lbl); - hbox.append(&desc_lbl); - row.set_child(Some(&hbox)); - list.append(&row); - } - } + populate_list(&list); let scroll = ScrolledWindow::new(); scroll.set_vexpand(true); @@ -133,32 +107,81 @@ pub fn build() -> GBox { let btn_row = GBox::new(Orientation::Horizontal, 8); btn_row.set_margin_top(12); + let refresh_btn = Button::with_label("Refresh"); let rollback_btn = Button::with_label("Rollback to selected"); let delete_btn = Button::with_label("Delete selected"); delete_btn.add_css_class("destructive-action"); { let list = list.clone(); - rollback_btn.connect_clicked(move |_| { - let Some(row) = list.selected_row() else { - return; - }; - let number = row.widget_name().to_string(); - confirm_rollback(&number); + refresh_btn.connect_clicked(move |_| { + populate_list(&list); }); } { let list = list.clone(); - delete_btn.connect_clicked(move |_| { - let Some(row) = list.selected_row() else { - return; - }; + rollback_btn.connect_clicked(move |btn| { + let Some(row) = list.selected_row() else { return }; let number = row.widget_name().to_string(); - let _ = Command::new("snapper").args(["delete", &number]).status(); + if number.is_empty() { return } + + let window = btn + .root() + .and_then(|r| r.downcast::().ok()); + + let dialog = AlertDialog::builder() + .message(&format!("Roll back to snapshot #{number}?")) + .detail("The current system state will be replaced on next boot. \ + A polkit prompt will ask for your password.") + .buttons(["Cancel", "Roll back"]) + .cancel_button(0) + .default_button(0) + .build(); + + dialog.choose(window.as_ref(), gtk4::gio::Cancellable::NONE, move |result| { + if result == Ok(1) { + // pkexec so polkit handles the privilege escalation + std::thread::spawn(move || { + let _ = Command::new("pkexec") + .args(["snapper", "rollback", &number]) + .status(); + }); + } + }); }); } + { + let list = list.clone(); + delete_btn.connect_clicked(move |btn| { + let Some(row) = list.selected_row() else { return }; + let number = row.widget_name().to_string(); + if number.is_empty() { return } + + let window = btn + .root() + .and_then(|r| r.downcast::().ok()); + + let list = list.clone(); + let dialog = AlertDialog::builder() + .message(&format!("Delete snapshot #{number}?")) + .detail("This cannot be undone.") + .buttons(["Cancel", "Delete"]) + .cancel_button(0) + .default_button(0) + .build(); + + dialog.choose(window.as_ref(), gtk4::gio::Cancellable::NONE, move |result| { + if result == Ok(1) { + let _ = Command::new("snapper").args(["delete", &number]).status(); + populate_list(&list); + } + }); + }); + } + + btn_row.append(&refresh_btn); btn_row.append(&rollback_btn); btn_row.append(&delete_btn); vbox.append(&btn_row);