From 1df4b5c275236cbc697beb7c891b95d0707d2ce8 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Tue, 9 Jun 2026 11:32:38 +0200 Subject: [PATCH 01/25] Add menuitems: remove and add a minion --- src/ui/alert.rs | 38 ++++++++++++++++++++++++++++++-------- src/ui/dslbrowser.rs | 2 +- src/ui/macts.rs | 25 ++++++++++++++++--------- src/ui/mod.rs | 37 +++++++++++++++++++++++++++++++++++-- 4 files changed, 82 insertions(+), 20 deletions(-) diff --git a/src/ui/alert.rs b/src/ui/alert.rs index 61adf0f5..8042f32d 100644 --- a/src/ui/alert.rs +++ b/src/ui/alert.rs @@ -3,7 +3,7 @@ use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Position}, prelude::{Buffer, Rect}, style::{Color, Modifier, Style}, - text::{Line, Span}, + text::{Line, Span, Text}, widgets::{Block, Borders, Clear, Padding, Paragraph, Widget}, }; use ratatui_glamour::color::blend_2d; @@ -51,6 +51,7 @@ impl SysInspectUX { Some(palette::WHITE), None, None, + None, Some((10.0, &[palette::GRAY_0, palette::BG_2] as &[Color])), ); } @@ -75,6 +76,7 @@ impl SysInspectUX { Some(palette::WHITE), None, None, + None, Some((10.0, &[palette::GRAY_0, palette::BG_2] as &[Color])), ); } @@ -100,6 +102,7 @@ impl SysInspectUX { None, None, None, + None, ); } @@ -124,6 +127,7 @@ impl SysInspectUX { None, Some("Yep!"), Some("Nope"), + None, Some((10.0, &[palette::GRAY_0, palette::BG_2] as &[Color])), ); } @@ -132,27 +136,41 @@ impl SysInspectUX { if !self.cluster_confirm_visible { return; } - let text = match self.pending_cluster_action { - 1 => "Shut down every online minion\nin the entire cluster?", - 2 => "Force every online minion to drop\nand re-establish its connection?", + let (plain_text, styled_text): (String, Option>) = match self.pending_cluster_action { + 1 => ("\nShut down every online minion\nin the entire cluster?".to_string(), None), + 2 => ("\nForce every online minion to drop\nand re-establish its connection?".to_string(), None), + 3 => { + let host = self.selected_popup_minion().map(|r| Self::online_host(&r)).unwrap_or_else(|| "unknown".to_string()); + let plain = format!("\nDo you want to unregister {host} from this cluster?"); + let styled = Text::from(vec![ + Line::from(""), + Line::from(vec![ + Span::raw("Do you want to unregister "), + Span::styled(host.clone(), Style::default().fg(palette::SUCCESS)), + Span::raw(" from this cluster?"), + ]), + ]); + (plain, Some(styled)) + } _ => return, }; Self::_popup_ex( parent, buf, Some("Cluster Operation"), - text, + &plain_text, None, Alignment::Center, self.cluster_confirm_choice.clone(), AlertButtons::YesNo, - Some(50), + Some(0), Some(palette::PROCESSING_PEAK), None, None, Some(palette::WHITE), None, None, + styled_text, Some((10.0, &[palette::GRAY_0, palette::BG_2] as &[Color])), ); } @@ -192,7 +210,7 @@ impl SysInspectUX { fn _popup_ex( parent: Rect, buf: &mut Buffer, title: Option<&str>, text: &str, background: Option, text_align: Alignment, choice: AlertResult, buttons: AlertButtons, width: Option, border_color: Option, border_type: Option, - text_color: Option, title_color: Option, left_label: Option<&str>, right_label: Option<&str>, + text_color: Option, title_color: Option, left_label: Option<&str>, right_label: Option<&str>, styled_text: Option>, gradient: Option<(f32, &[Color])>, ) { let background = background.unwrap_or(palette::POPUP_BG_BASE); @@ -257,7 +275,11 @@ impl SysInspectUX { let text_area = vertical_chunks[0]; let button_area = vertical_chunks[1]; - Paragraph::new(text).alignment(text_align).style(text_bg).render(text_area, buf); + if let Some(st) = styled_text { + Paragraph::new(st).alignment(text_align).style(text_bg).render(text_area, buf); + } else { + Paragraph::new(text).alignment(text_align).style(text_bg).render(text_area, buf); + } let (lbtn_label, rbtn_label) = match buttons { AlertButtons::YesNo => (Self::format_button(YES_LABEL), Self::format_button(NO_LABEL)), AlertButtons::OkCancel => (Self::format_button(left_label.unwrap_or(OK_LABEL)), Self::format_button(right_label.unwrap_or(CANCEL_LABEL))), diff --git a/src/ui/dslbrowser.rs b/src/ui/dslbrowser.rs index e2ad42c9..021f220b 100644 --- a/src/ui/dslbrowser.rs +++ b/src/ui/dslbrowser.rs @@ -834,7 +834,7 @@ impl SysInspectUX { let y = parent.y + (parent.height.saturating_sub(h)) / 2; let canvas = Rect { x, y, width: w, height: h }; - let bg = palette::POPUP_BG_1; + let _bg = palette::POPUP_BG_1; Clear.render(canvas, buf); let grad_colors = blend_2d(canvas.width as usize, canvas.height as usize, 10.0, &[palette::BG_1, palette::BG_2] as &[ratatui::style::Color]); diff --git a/src/ui/macts.rs b/src/ui/macts.rs index 5843630e..52c5f200 100644 --- a/src/ui/macts.rs +++ b/src/ui/macts.rs @@ -14,13 +14,19 @@ use unicode_width::UnicodeWidthStr; struct MenuSection { title: &'static str, - items: &'static [(&'static str, char)], + items: &'static [(&'static str, &'static str)], } const MENU_SECTIONS: &[MenuSection] = &[ - MenuSection { title: "Tools", items: &[("System logs", 'L'), ("Defined traits", 'T')] }, - MenuSection { title: "Minion Operations", items: &[("Remote start", 'S'), ("Shutdown minion", 'D'), ("Force re-connect", 'F')] }, - MenuSection { title: "Cluster Operations", items: &[("Shutdown everything", 'X'), ("Reconnect all minions", 'A')] }, + MenuSection { title: "Tools", items: &[("System logs", "^L"), ("Defined traits", "^T")] }, + MenuSection { + title: "Minion Operations", + items: &[("Remote start", "^S"), ("Shutdown minion", "^D"), ("Force re-connect", "^F"), ("Delete minion", "DEL")], + }, + MenuSection { + title: "Cluster Operations", + items: &[("Shutdown everything", "^X"), ("Reconnect all minions", "^A"), ("Register a new minion", "INS")], + }, ]; pub(crate) fn total_menu_items() -> usize { @@ -52,7 +58,7 @@ impl SysInspectUX { let max_item_w = max_label_w + 34; let mut title_style = TitleStyle::cyberpunk(palette::PROCESSING_GLOW); - let is_cluster = self.minions_menu_sel >= 5; + let is_cluster = self.minions_menu_sel >= 6; let mut segments = vec![TitleSegment { text: " Actions on ".into(), bg: palette::PROCESSING_GLOW, fg: palette::FG }]; if is_cluster { segments.push(TitleSegment { text: " Cluster ".into(), bg: palette::PROCESSING_PEAK, fg: palette::FG }); @@ -125,15 +131,16 @@ impl SysInspectUX { let selected = flat_idx == self.minions_menu_sel; let item_style = if selected { Style::default().fg(palette::BLACK).bg(palette::HIGHLIGHT) } else { Style::default().fg(palette::FG) }; - let hint = format!("^{key}"); - let padding = (inner.width as usize).saturating_sub(label.len() + 1 + hint.len()).saturating_sub(2); // one space on each side + let hint = key; + let padding = + (inner.width as usize).saturating_sub(UnicodeWidthStr::width(label) + 1 + UnicodeWidthStr::width(hint)).saturating_sub(2); let line = format!(" {label}{}{hint} ", " ".repeat(padding)); buf.set_string(inner.x, row_y, &line, item_style); // Re-paint just the key hint with its own style on top - let hint_x = inner.x + (inner.width.saturating_sub(hint.len() as u16 + 2)); + let hint_x = inner.x + (inner.width.saturating_sub(UnicodeWidthStr::width(hint) as u16 + 2)); let hint_sel_style = if selected { Style::default().fg(palette::BG_0).bg(palette::HIGHLIGHT) } else { hint_style }; - buf.set_string(hint_x, row_y, &hint, hint_sel_style); + buf.set_string(hint_x, row_y, hint, hint_sel_style); row_y += 1; flat_idx += 1; diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 24800a95..9a5ee4a1 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -144,7 +144,7 @@ pub struct SysInspectUX { // Cluster-wide operation confirmation pub cluster_confirm_visible: bool, pub cluster_confirm_choice: AlertResult, - pub pending_cluster_action: u8, // 0=none, 1=shutdown all, 2=reconnect all + pub pending_cluster_action: u8, // 0=none, 1=shutdown all, 2=reconnect all, 3=delete minion // Tag popup pub tag_visible: bool, @@ -654,11 +654,17 @@ impl SysInspectUX { } else if self.minions_menu_sel == 5 { self.cluster_confirm_visible = true; self.cluster_confirm_choice = AlertResult::ClusterConfirm; - self.pending_cluster_action = 1; + self.pending_cluster_action = 3; } else if self.minions_menu_sel == 6 { + self.cluster_confirm_visible = true; + self.cluster_confirm_choice = AlertResult::ClusterConfirm; + self.pending_cluster_action = 1; + } else if self.minions_menu_sel == 7 { self.cluster_confirm_visible = true; self.cluster_confirm_choice = AlertResult::ClusterConfirm; self.pending_cluster_action = 2; + } else if self.minions_menu_sel == 8 { + // TODO: do_minion_add() } } _ => { @@ -1053,6 +1059,16 @@ impl SysInspectUX { self.pending_cluster_action = 2; true } + KeyCode::Insert => { + // TODO: do_minion_add() + true + } + KeyCode::Delete => { + self.cluster_confirm_visible = true; + self.cluster_confirm_choice = AlertResult::ClusterConfirm; + self.pending_cluster_action = 3; + true + } _ => false, } } @@ -1075,6 +1091,7 @@ impl SysInspectUX { match self.pending_cluster_action { 1 => self.do_cluster_shutdown(), 2 => self.do_cluster_reconnect(), + 3 => self.do_minion_delete(), _ => {} } } @@ -1119,6 +1136,22 @@ impl SysInspectUX { } } + fn do_minion_delete(&mut self) { + let row = match self.selected_popup_minion() { + Some(row) => row, + None => { + self.error_alert_visible = true; + self.error_alert_message = "No minion selected".to_string(); + self.status_at_minions_browser(); + return; + } + }; + let _host = Self::online_host(&row); + let _mid = row.minion_id.clone(); + // TODO: call_master_console with CLUSTER_REMOVE_MINION + self.status_at_minions_browser(); + } + fn open_logs_popup(&mut self) { self.minion_logs_visible = true; self.minion_logs_filter = ratatui_cheese::input::InputState::new(); From cc1de52aa88acd8d1b3719b8603daa79e86d2ad8 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Wed, 10 Jun 2026 16:17:55 +0200 Subject: [PATCH 02/25] Implement new sysmaster configuration setup via TUI --- Cargo.lock | 1 + Cargo.toml | 1 + libsysinspect/src/cfg/mmconf.rs | 36 ++- src/main.rs | 25 +- src/ui/alert.rs | 29 +- src/ui/mod.rs | 309 ++++++++++++++++++--- src/ui/setup.rs | 476 ++++++++++++++++++++++++++++++++ src/ui/wgt.rs | 6 + 8 files changed, 816 insertions(+), 67 deletions(-) create mode 100644 src/ui/setup.rs diff --git a/Cargo.lock b/Cargo.lock index bbc1f9b4..d0be52c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8444,6 +8444,7 @@ dependencies = [ "ratatui-cheese", "ratatui-glamour", "serde_json", + "serde_yaml", "sysinfo 0.33.1", "tokio", "unicode-width 0.2.2", diff --git a/Cargo.toml b/Cargo.toml index 6874c109..4fc32cab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ crossterm = "0.28.1" rand = "0.9.4" indexmap = "2.14.0" serde_json = "1.0.150" +serde_yaml = "0.9" jsonpath_lib = "0.3.0" openssl = { version = "0.10.80", features = ["vendored"] } ratatui-cheese = "0.7.0" diff --git a/libsysinspect/src/cfg/mmconf.rs b/libsysinspect/src/cfg/mmconf.rs index b2fbac40..093c3ebf 100644 --- a/libsysinspect/src/cfg/mmconf.rs +++ b/libsysinspect/src/cfg/mmconf.rs @@ -4,7 +4,7 @@ use libcommon::SysinspectError; use nix::libc; use serde::{Deserialize, Serialize}; use serde_yaml::{Value, from_str, from_value}; -use std::{fs, os::unix::fs::PermissionsExt, path::PathBuf, time::Duration}; +use std::{env, fs, os::unix::fs::PermissionsExt, path::PathBuf, time::Duration}; // Network // ------- @@ -1057,6 +1057,9 @@ pub struct MasterConfig { #[serde(rename = "bind.port")] bind_port: Option, + #[serde(rename = "root")] + root: Option, + // Path to FIFO socket. Default: /var/run/sysinspect-master.socket socket: Option, @@ -1438,9 +1441,30 @@ impl MasterConfig { self.fileserver_root().join(CFG_PROFILES_ROOT) } - /// Get default sysinspect root. For master it is always /etc/sysinspect + /// Get default sysinspect root. Auto-discovered from binary location or config. pub fn root_dir(&self) -> PathBuf { - PathBuf::from(DEFAULT_SYSINSPECT_ROOT.to_string()) + // 1. Explicit config field + if let Some(ref r) = self.root { + return PathBuf::from(r); + } + // 2. System binary → /etc/sysinspect (RPM/deb) + if let Ok(exe) = env::current_exe() + && let Some(parent) = exe.parent() + { + let parent_str = parent.to_string_lossy(); + if parent_str == "/usr/bin" || parent_str == "/usr/sbin" || parent_str == "/bin" || parent_str == "/sbin" { + return PathBuf::from(DEFAULT_SYSINSPECT_ROOT); + } + } + // 3. Self-contained layout: ../etc/sysinspect.conf exists relative to binary + if let Ok(exe) = env::current_exe() + && let Some(grandparent) = exe.parent().and_then(|p| p.parent()) + && grandparent.join("etc").join(APP_CONF).exists() + { + return grandparent.to_path_buf(); + } + // 4. Fallback + PathBuf::from(DEFAULT_SYSINSPECT_ROOT) } /// Resolve a path under the SysInspect root unless it is already absolute. @@ -1515,12 +1539,12 @@ impl MasterConfig { /// Return the path of the telemetry location pub fn telemetry_location(&self) -> PathBuf { - self.telemetry_location.as_deref().map(PathBuf::from).unwrap_or_else(|| PathBuf::from(DEFAULT_MASTER_TELEMETRY_DB)) + self.telemetry_location.as_deref().map(PathBuf::from).unwrap_or_else(|| self.root_dir().join("telemetry")) } /// Return the path of the telemetry communication socket location pub fn telemetry_socket(&self) -> PathBuf { - self.telemetry_socket.as_deref().map(PathBuf::from).unwrap_or_else(|| PathBuf::from(DEFAULT_MASTER_TELEMETRY_SCK)) + self.telemetry_socket.as_deref().map(PathBuf::from).unwrap_or_else(|| self.root_dir().join("telemetry/master.sock")) } /// Return the path of the telemetry communication socket location @@ -1540,7 +1564,7 @@ impl MasterConfig { /// Get datastore path pub fn datastore_path(&self) -> PathBuf { - self.datastore_path.as_deref().map(PathBuf::from).unwrap_or_else(|| PathBuf::from(DEFAULT_DATASTORE_ROOT)) + self.datastore_path.as_deref().map(PathBuf::from).unwrap_or_else(|| self.root_dir().join("datastore")) } /// Get datastore max size in bytes diff --git a/src/main.rs b/src/main.rs index 8ade22f2..d11f1589 100644 --- a/src/main.rs +++ b/src/main.rs @@ -339,12 +339,18 @@ async fn main() { std::process::exit(0); } - // Get master config + // Get master config — for --ui, allow missing config (setup wizard handles it) + let is_ui = *params.get_one::("ui").unwrap_or(&false); + let config_found = get_cfg(¶ms).is_ok(); let cfg = match get_cfg(¶ms) { Ok(cfg) => cfg, Err(err) => { - log::error!("Unable to get master configuration: {err}"); - std::process::exit(1); + if is_ui { + libsysinspect::cfg::mmconf::MasterConfig::default() + } else { + log::error!("Unable to get master configuration: {err}"); + std::process::exit(1); + } } }; @@ -515,17 +521,8 @@ async fn main() { print_event_handlers(); return; } else if *params.get_one::("ui").unwrap_or(&false) { - if let Err(err) = ui::run(cfg).await { - let x = err.kind(); - if x == ErrorKind::InvalidData { - println!( - "Can't start the UI: {}.\nIs {} running and reachable?\n", - err.to_string().bright_red(), - "SysInspect Master".bright_yellow() - ); - } else { - println!("Unexpected error: {}", err.to_string().bright_red()) - } + if let Err(err) = ui::run(cfg, config_found).await { + println!("Unexpected error: {}", err.to_string().bright_red()); } return; } diff --git a/src/ui/alert.rs b/src/ui/alert.rs index 8042f32d..480ac7e0 100644 --- a/src/ui/alert.rs +++ b/src/ui/alert.rs @@ -56,6 +56,31 @@ impl SysInspectUX { ); } + pub fn dialog_info(&self, parent: Rect, buf: &mut Buffer, title: &str, text: &str, quit_button: bool) { + let max_w = ((parent.width * 3 / 4).max(50)) as usize; + let wrapped_lines = wrap_text(text, max_w); + let text = if wrapped_lines.is_empty() { "".to_string() } else { wrapped_lines.join("\n") }; + Self::_popup_ex( + parent, + buf, + Some(title), + &text, + None, + Alignment::Left, + AlertResult::Quit, + if quit_button { AlertButtons::Quit } else { AlertButtons::Close }, + Some(0), + Some(palette::SUCCESS_PEAK), + None, + None, + Some(palette::BG_1), + None, + None, + None, + Some((10.0, &[palette::GRAY_0, palette::BG_2] as &[Color])), + ); + } + pub fn dialog_purge(&self, parent: Rect, buf: &mut Buffer) { if !self.purge_alert_visible { return; @@ -284,7 +309,7 @@ impl SysInspectUX { AlertButtons::YesNo => (Self::format_button(YES_LABEL), Self::format_button(NO_LABEL)), AlertButtons::OkCancel => (Self::format_button(left_label.unwrap_or(OK_LABEL)), Self::format_button(right_label.unwrap_or(CANCEL_LABEL))), AlertButtons::Ok => (Self::format_button(OK_LABEL), "".to_string()), - AlertButtons::Quit => (Self::format_button(CLOSE_LABEL), "".to_string()), + AlertButtons::Quit => (Self::format_button(QUIT_LABEL), "".to_string()), AlertButtons::Close => (Self::format_button(CLOSE_LABEL), "".to_string()), }; @@ -406,7 +431,7 @@ impl SysInspectUX { AlertButtons::YesNo => (Self::format_button(YES_LABEL), Self::format_button(NO_LABEL)), AlertButtons::OkCancel => (Self::format_button(OK_LABEL), Self::format_button(CANCEL_LABEL)), AlertButtons::Ok => (Self::format_button(OK_LABEL), "".to_string()), - AlertButtons::Quit => (Self::format_button(CLOSE_LABEL), "".to_string()), + AlertButtons::Quit => (Self::format_button(QUIT_LABEL), "".to_string()), AlertButtons::Close => (Self::format_button(CLOSE_LABEL), "".to_string()), }; diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 9a5ee4a1..3c91c8b7 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -21,15 +21,15 @@ use libsysproto::query::{ }; use ratatui::{ DefaultTerminal, Frame, - layout::{Constraint, Direction, Layout}, - style::Style, - text::Line, + layout::{Constraint, Direction, Layout, Rect}, + style::{Modifier, Style}, + text::{Line, Span}, widgets::{Paragraph, Row}, }; use ratatui_cheese::tree::TreeState; use std::{ cell::{Cell, RefCell}, - io::{self, Error}, + io::{self}, sync::Arc, time::{Duration, Instant}, }; @@ -43,6 +43,7 @@ mod macts; mod online; mod palette; mod rawlogs; +mod setup; mod statusbar; mod title; mod traitsview; @@ -50,23 +51,40 @@ mod traittag; mod typecolors; mod wgt; -pub async fn run(cfg: MasterConfig) -> io::Result<()> { - match SysInspectUX::new(cfg.clone()).await { - Ok(mut app) => { - let mut terminal = ratatui::init(); - let r = app.run(&mut terminal); - ratatui::restore(); - - // XXX: Temporary log dumper. Should go to its own window popup later - if !MEM_LOGGER.get_messages().is_empty() { - println!("Memory log:"); - println!("{:#?}", MEM_LOGGER.get_messages()); +pub async fn run(cfg: MasterConfig, config_found: bool) -> io::Result<()> { + let mut terminal = ratatui::init(); + let (result, exit_message) = match SysInspectUX::new(cfg.clone()).await { + Ok(app) => (app.run_loop(&mut terminal), None), + Err(err) => { + if config_found { + let app = SysInspectUX { + cfg, + offline: true, + error_alert_visible: true, + error_alert_message: format!("Cannot connect to master.\n\n{err}\n\nStart the master, then reconnect."), + ..Default::default() + }; + (app.run_offline_loop(&mut terminal), None) + } else { + let app = SysInspectUX { cfg, setup_wizard: setup::MasterSetupWizard { visible: true, ..Default::default() }, ..Default::default() }; + let mut exit_message = None; + let r = app.run_setup_loop(&mut terminal, &mut exit_message); + (r, exit_message) } - - r } - Err(err) => Err(Error::new(io::ErrorKind::InvalidData, err)), + }; + ratatui::restore(); + + if let Some(msg) = exit_message { + println!("\n{msg}\n"); + } + + if !MEM_LOGGER.get_messages().is_empty() { + println!("Memory log:"); + println!("{:#?}", MEM_LOGGER.get_messages()); } + + result } #[derive(Debug, Clone, Copy, Default)] @@ -89,7 +107,7 @@ pub struct SysInspectUX { pub event_data: IndexMap, pub active_box: ActiveBox, saved_active_box: Option, - main_focus_suspended: bool, + no_focus: bool, pub status_text: Line<'static>, @@ -102,6 +120,10 @@ pub struct SysInspectUX { pub error_alert_message: String, pub error_alert_choice: AlertResult, + /// Information alert (success/info popups) + pub info_alert_visible: bool, + pub info_alert_message: String, + /// Exit alert pub exit_alert_visible: bool, pub exit_alert_choice: AlertResult, @@ -162,6 +184,17 @@ pub struct SysInspectUX { pub cfg: MasterConfig, + // Master setup wizard (first-run) + pub setup_wizard: setup::MasterSetupWizard, + + // Connection state + pub offline: bool, + pub last_reconnect_attempt: Instant, + + // Exit-after-popup state (for setup config-written notice) + pub pending_exit: bool, + pub pending_exit_message: Option, + // Buffers pub cycles_buf: Vec, pub minions_buf: Vec, @@ -184,7 +217,7 @@ impl Default for SysInspectUX { event_data: IndexMap::new(), active_box: ActiveBox::default(), saved_active_box: None, - main_focus_suspended: false, + no_focus: false, status_text: Line::from(vec![]), // Alerts @@ -195,6 +228,8 @@ impl Default for SysInspectUX { error_alert_visible: false, error_alert_choice: AlertResult::default(), error_alert_message: String::new(), + info_alert_visible: false, + info_alert_message: String::new(), help_popup_visible: false, minions_visible: false, @@ -239,6 +274,12 @@ impl Default for SysInspectUX { evtipc: None, dsl_browser: dslbrowser::DslBrowser::new(), cfg: MasterConfig::default(), + setup_wizard: setup::MasterSetupWizard::default(), + offline: false, + last_reconnect_attempt: Instant::now(), + + pending_exit: false, + pending_exit_message: None, cycles_buf: Vec::new(), minions_buf: Vec::new(), events_buf: Vec::new(), @@ -263,13 +304,128 @@ impl SysInspectUX { Ok(ux) } - pub fn run(&mut self, term: &mut DefaultTerminal) -> io::Result<()> { - self.cycles_buf = self.get_cycles().unwrap(); + pub fn run_loop(mut self, term: &mut DefaultTerminal) -> io::Result<()> { + self.cycles_buf = self.get_cycles().unwrap_or_default(); + self.run_normal_loop(term) + } + + pub fn run_setup_loop(mut self, term: &mut DefaultTerminal, exit_msg: &mut Option) -> io::Result<()> { + self.last_reconnect_attempt = Instant::now(); + self.no_focus = true; + while !self.exit { + term.draw(|frame| self.draw(frame))?; + if self.setup_wizard.ok_pressed { + match self.setup_wizard.write_config() { + Ok(config_path) => { + self.setup_wizard.ok_pressed = false; + self.setup_wizard.visible = false; + let msg = format!( + "Config written to:\n{}\n\nStart the master with:\n sysmaster --start -c {}", + config_path.display(), + config_path.display(), + ); + self.info_alert_visible = true; + self.info_alert_message = msg.clone(); + self.pending_exit = true; + self.pending_exit_message = Some(msg); + } + Err(e) => { + self.setup_wizard.error_message = Some(e); + self.setup_wizard.ok_pressed = false; + } + } + } + if !self.error_alert_visible && !self.setup_wizard.visible && self.try_reconnect_silent().is_ok() { + return self.run_normal_loop(term); + } + // Periodic silent reconnect in setup mode + if !self.setup_wizard.ok_pressed && self.last_reconnect_attempt.elapsed() >= Duration::from_secs(5) && self.evtipc.is_some() { + self.last_reconnect_attempt = Instant::now(); + if self.try_reconnect_silent().is_ok() { + return self.run_normal_loop(term); + } + } + self.on_events_setup()?; + } + *exit_msg = self.pending_exit_message.take(); + Ok(()) + } + + fn run_normal_loop(mut self, term: &mut DefaultTerminal) -> io::Result<()> { + self.last_reconnect_attempt = Instant::now(); while !self.exit { self.sync_main_focus_for_overlays(); term.draw(|frame| self.draw(frame))?; self.on_events()?; + if self.offline && self.last_reconnect_attempt.elapsed() >= Duration::from_secs(5) { + self.last_reconnect_attempt = Instant::now(); + if self.try_reconnect_silent().is_ok() { + self.offline = false; + } + } + } + Ok(()) + } + + pub fn run_offline_loop(mut self, term: &mut DefaultTerminal) -> io::Result<()> { + self.last_reconnect_attempt = Instant::now(); + self.no_focus = true; + + while !self.exit { + term.draw(|frame| self.draw(frame))?; + // Periodic silent reconnect attempt + if self.last_reconnect_attempt.elapsed() >= Duration::from_secs(5) { + self.last_reconnect_attempt = Instant::now(); + if self.try_reconnect_silent().is_ok() { + return self.run_normal_loop(term); + } + } + self.on_events()?; + } + Ok(()) + } + + fn try_reconnect_silent(&mut self) -> Result<(), String> { + let socket = self.cfg.telemetry_socket(); + match tokio::task::block_in_place(|| { + tokio::runtime::Handle::current().block_on(async { DbIPCClient::new(socket.to_str().unwrap_or_default()).await }) + }) { + Ok(ipc) => { + self.evtipc = Some(Arc::new(Mutex::new(ipc))); + self.setup_wizard.visible = false; + self.error_alert_visible = false; + self.offline = false; + self.cycles_buf = self.get_cycles().unwrap_or_default(); + Ok(()) + } + Err(e) => Err(e.to_string()), + } + } + + fn try_reconnect(&mut self) -> Result<(), String> { + self.try_reconnect_silent().map_err(|e| { + self.error_alert_visible = true; + self.error_alert_message = format!("Master still not reachable: {e}"); + e + }) + } + + fn on_events_setup(&mut self) -> io::Result<()> { + if event::poll(Duration::from_secs(1))? + && let Event::Key(e) = event::read()? + && e.kind == KeyEventKind::Press + { + if self.setup_wizard.visible && !self.error_alert_visible && !self.exit_alert_visible && !self.info_alert_visible { + self.setup_wizard.handle_key(e); + if self.setup_wizard.quit_requested { + self.setup_wizard.quit_requested = false; + self.exit_alert_visible = true; + self.exit_alert_choice = AlertResult::Default; + } + } else { + self.on_key(e); + } } Ok(()) } @@ -284,8 +440,29 @@ impl SysInspectUX { frame.render_widget(self, main_area); - let status_paragraph = Paragraph::new(self.status_text.clone()).style(Style::default().fg(self::palette::GRAY_1).bg(self::palette::BG_1)); - frame.render_widget(status_paragraph, status_area); + if self.offline { + let offline_w: u16 = 14; + let [main_status, offline_area]: [Rect; 2] = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Min(0), Constraint::Length(offline_w)].as_ref()) + .split(status_area) + .as_ref() + .try_into() + .unwrap(); + + let status_paragraph = Paragraph::new(self.status_text.clone()).style(Style::default().fg(self::palette::GRAY_1).bg(self::palette::BG_1)); + frame.render_widget(status_paragraph, main_status); + + let offline_paragraph = Paragraph::new(Line::from(vec![Span::styled( + " Offline \u{2716} ", + Style::default().fg(palette::ERROR_PEAK).bg(palette::BG_1).add_modifier(Modifier::BOLD), + )])) + .style(Style::default().bg(palette::BG_1)); + frame.render_widget(offline_paragraph, offline_area); + } else { + let status_paragraph = Paragraph::new(self.status_text.clone()).style(Style::default().fg(self::palette::GRAY_1).bg(self::palette::BG_1)); + frame.render_widget(status_paragraph, status_area); + } } fn on_events(&mut self) -> io::Result<()> { @@ -297,16 +474,24 @@ impl SysInspectUX { self.on_key(e); } } else { - if let Ok(cycles) = self.get_cycles() { - self.cycles_buf = cycles; - } - if self.minions_visible { - self.refresh_minions(); - } - if self.minion_logs_visible && self.minion_logs_polling && self.minion_logs_last_fetch.elapsed() >= Duration::from_secs(3) { - match self.load_selected_minion_logs() { - Ok(()) => self.minion_logs_online = true, - Err(_) => self.minion_logs_online = false, + if !self.offline { + match self.get_cycles() { + Ok(cycles) => self.cycles_buf = cycles, + Err(_) => { + self.offline = true; + self.evtipc = None; + } + } + if !self.offline { + if self.minions_visible { + self.refresh_minions(); + } + if self.minion_logs_visible && self.minion_logs_polling && self.minion_logs_last_fetch.elapsed() >= Duration::from_secs(3) { + match self.load_selected_minion_logs() { + Ok(()) => self.minion_logs_online = true, + Err(_) => self.minion_logs_online = false, + } + } } } } @@ -315,7 +500,7 @@ impl SysInspectUX { /// Cycle active pan to the right (used on RIGHT or ENTER key) fn shift_next(&mut self) { - if self.main_focus_suspended { + if self.no_focus { return; } match self.active_box { @@ -344,7 +529,7 @@ impl SysInspectUX { /// Cycle active pan to the left (used on LEFT or ESC key) fn shift_prev(&mut self) { - if self.main_focus_suspended { + if self.no_focus { return; } match self.active_box { @@ -461,6 +646,20 @@ impl SysInspectUX { stat } + fn on_info_alert(&mut self, e: event::KeyEvent) -> bool { + if !self.info_alert_visible { + return false; + } + if matches!(e.code, KeyCode::Enter | KeyCode::Esc) { + self.info_alert_visible = false; + if self.pending_exit { + self.pending_exit = false; + self.exit(); + } + } + true + } + /// Process online minions popup key events fn on_minions_popup(&mut self, e: event::KeyEvent) -> bool { if !self.minions_visible { @@ -871,17 +1070,17 @@ impl SysInspectUX { fn sync_main_focus_for_overlays(&mut self) { let overlay_visible = self.any_overlay_visible(); - if overlay_visible && !self.main_focus_suspended { + if overlay_visible && !self.no_focus { self.saved_active_box = Some(self.active_box); - self.main_focus_suspended = true; - } else if !overlay_visible && self.main_focus_suspended { + self.no_focus = true; + } else if !overlay_visible && self.no_focus { self.active_box = self.saved_active_box.take().unwrap_or_default(); - self.main_focus_suspended = false; + self.no_focus = false; } } pub(crate) fn main_box_active(&self, hl: ActiveBox) -> bool { - !self.main_focus_suspended && self.active_box == hl + !self.no_focus && self.active_box == hl } fn refresh_minions(&mut self) { @@ -1470,6 +1669,30 @@ impl SysInspectUX { return; } + // Information alert is modal + if self.on_info_alert(e) { + return; + } + + // Exit alert takes priority over setup wizard + if self.on_exit_alert(e) { + return; + } + + // Setup wizard is modal + if self.setup_wizard.visible { + self.setup_wizard.handle_key(e); + if self.setup_wizard.quit_requested { + self.setup_wizard.quit_requested = false; + self.exit_alert_visible = true; + self.exit_alert_choice = AlertResult::Default; + } + return; + } + if self.on_error_alert(e) { + return; + } + if self.dsl_browser.visible { self.dsl_browser.handle_key(e.code); if !self.dsl_browser.visible { @@ -1518,10 +1741,6 @@ impl SysInspectUX { return; } - if self.on_exit_alert(e) { - return; - } - if self.on_cluster_confirm(e) { return; } diff --git a/src/ui/setup.rs b/src/ui/setup.rs new file mode 100644 index 00000000..8f6fdfed --- /dev/null +++ b/src/ui/setup.rs @@ -0,0 +1,476 @@ +use super::{ + palette, + title::{self, TitleSegment, TitleStyle}, +}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use libsysinspect::cfg::mmconf::{MasterConfig, SysInspectConfig}; +use ratatui::{ + layout::Position, + prelude::{Buffer, Rect}, + style::{Color, Modifier, Style}, + widgets::{Block, BorderType, Borders, Clear, StatefulWidget, Widget}, +}; +use ratatui_cheese::input::{Input, InputState}; +use ratatui_glamour::color::blend_2d; +use ratatui_glamour::rule::dashed_title; +use unicode_width::UnicodeWidthStr; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum InstallationMode { + SystemWide, + Custom, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum SetupFocus { + SystemRadio, + CustomRadio, + CustomDest, + BindAddr, + BindPort, + FsPort, + ApiCheck, + Ok, + Cancel, +} + +impl SetupFocus { + fn next(self) -> Self { + use SetupFocus::*; + match self { + SystemRadio => CustomRadio, + CustomRadio => CustomDest, + CustomDest => BindAddr, + BindAddr => BindPort, + BindPort => FsPort, + FsPort => ApiCheck, + ApiCheck => Ok, + Ok => Cancel, + Cancel => SystemRadio, + } + } + + fn prev(self) -> Self { + use SetupFocus::*; + match self { + SystemRadio => Cancel, + CustomRadio => SystemRadio, + CustomDest => CustomRadio, + BindAddr => CustomDest, + BindPort => BindAddr, + FsPort => BindPort, + ApiCheck => FsPort, + Ok => ApiCheck, + Cancel => Ok, + } + } +} + +#[derive(Debug)] +pub struct MasterSetupWizard { + pub visible: bool, + pub installation_mode: InstallationMode, + pub custom_destination: InputState, + pub bind_addr: InputState, + pub bind_port: InputState, + pub fs_port: InputState, + pub api_enabled: bool, + pub focus: SetupFocus, + pub ok_pressed: bool, + pub quit_requested: bool, + pub error_message: Option, +} + +impl Default for MasterSetupWizard { + fn default() -> Self { + let cwd = std::env::current_dir().map(|p| p.to_string_lossy().to_string()).unwrap_or_default(); + + let mut custom_dest = InputState::new(); + custom_dest.set_value(cwd); + + let mut bind_addr = InputState::new(); + bind_addr.set_value("0.0.0.0".to_string()); + + let mut bind_port = InputState::new(); + bind_port.set_value("4200".to_string()); + + let mut fs_port = InputState::new(); + fs_port.set_value("4201".to_string()); + + Self { + visible: false, + installation_mode: InstallationMode::SystemWide, + custom_destination: custom_dest, + bind_addr, + bind_port, + fs_port, + api_enabled: true, + focus: SetupFocus::SystemRadio, + ok_pressed: false, + quit_requested: false, + error_message: None, + } + } +} + +impl MasterSetupWizard { + #[allow(clippy::too_many_arguments)] + pub fn handle_key(&mut self, key: KeyEvent) -> bool { + if !self.visible { + return false; + } + match key.code { + KeyCode::Tab => { + self.focus = if key.modifiers.contains(KeyModifiers::SHIFT) { self.focus.prev() } else { self.focus.next() }; + } + KeyCode::BackTab => { + self.focus = self.focus.prev(); + } + KeyCode::Enter => match self.focus { + SetupFocus::Ok => { + self.ok_pressed = true; + } + SetupFocus::Cancel => { + self.quit_requested = true; + } + SetupFocus::SystemRadio => { + self.installation_mode = InstallationMode::SystemWide; + } + SetupFocus::CustomRadio => { + self.installation_mode = InstallationMode::Custom; + } + SetupFocus::ApiCheck => { + self.api_enabled = !self.api_enabled; + } + _ => {} // input fields — Enter does nothing (text is handled by char keys) + }, + KeyCode::Esc => { + self.quit_requested = true; + } + KeyCode::Char(' ') => { + if self.focus == SetupFocus::ApiCheck { + self.api_enabled = !self.api_enabled; + } + } + KeyCode::Backspace => { + if let Some(i) = self.focused_input_mut() { + i.delete_before() + } + } + KeyCode::Delete => { + if let Some(i) = self.focused_input_mut() { + i.delete_at() + } + } + KeyCode::Left => { + if let Some(i) = self.focused_input_mut() { + i.move_left() + } + } + KeyCode::Right => { + if let Some(i) = self.focused_input_mut() { + i.move_right() + } + } + KeyCode::Home => { + if let Some(i) = self.focused_input_mut() { + i.home() + } + } + KeyCode::End => { + if let Some(i) = self.focused_input_mut() { + i.end() + } + } + KeyCode::Char(c) => { + if let Some(i) = self.focused_input_mut() { + i.insert_char(c) + } + } + _ => {} + } + true + } + + fn focused_input_mut(&mut self) -> Option<&mut InputState> { + match self.focus { + SetupFocus::CustomDest => Some(&mut self.custom_destination), + SetupFocus::BindAddr => Some(&mut self.bind_addr), + SetupFocus::BindPort => Some(&mut self.bind_port), + SetupFocus::FsPort => Some(&mut self.fs_port), + _ => None, + } + } + + fn is_focused(&self, target: SetupFocus) -> bool { + self.focus == target + } + + pub fn render(&self, parent: Rect, buf: &mut Buffer) { + if !self.visible { + return; + } + let dlg_w = (parent.width * 3 / 4).clamp(60, 72); + let dlg_h = if self.installation_mode == InstallationMode::Custom { 15u16 } else { 14u16 }; + let x = parent.x + (parent.width.saturating_sub(dlg_w)) / 2; + let y = parent.y + (parent.height.saturating_sub(dlg_h)) / 2; + let canvas = Rect { x, y, width: dlg_w, height: dlg_h }; + + Clear.render(canvas, buf); + + let grad = blend_2d(canvas.width as usize, canvas.height as usize, 10.0, &[palette::GRAY_0, palette::BG_2] as &[Color]); + for row in 0..canvas.height { + for col in 0..canvas.width { + let idx = row as usize * canvas.width as usize + col as usize; + if let Some(cell) = buf.cell_mut(Position::new(canvas.x + col, canvas.y + row)) { + cell.set_bg(grad[idx]); + } + } + } + + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(palette::PROCESSING_GLOW)) + .style(Style::default()); + let inner = block.inner(canvas); + block.render(canvas, buf); + + let title_style = TitleStyle::cyberpunk(palette::PROCESSING_GLOW); + title::overlay_gradient_title( + buf, + canvas, + &title_style, + &[TitleSegment { text: " Master Setup ".into(), bg: palette::PROCESSING_BASE, fg: palette::FG }], + ); + + if inner.height < 3 { + return; + } + + let label_w = 20u16; + let focus_style = Style::default().fg(palette::ACCENT).add_modifier(Modifier::BOLD); + let muted = Style::default().fg(palette::MUTED); + + let mut row_y = inner.y; + + // ── Installation section ── + dashed_title( + Rect { x: inner.x, y: row_y, width: inner.width, height: 1 }, + buf, + " Installation ", + palette::PROCESSING, + palette::PROCESSING, + palette::PROCESSING_DIMMED, + ); + row_y += 1; + + // Radio buttons — each on its own line + let sys_checked = self.installation_mode == InstallationMode::SystemWide; + let sys_style = if self.is_focused(SetupFocus::SystemRadio) { focus_style } else { muted }; + let cus_style = if self.is_focused(SetupFocus::CustomRadio) { focus_style } else { muted }; + + let sys_bullet = if sys_checked { "(•)" } else { "( )" }; + let cus_bullet = if sys_checked { "( )" } else { "(•)" }; + buf.set_string(inner.x + 3, row_y, format!(" {sys_bullet} System wide (/usr/bin) "), sys_style); + row_y += 1; + buf.set_string(inner.x + 3, row_y, format!(" {cus_bullet} Custom "), cus_style); + row_y += 1; + + // Custom destination row (only when Custom is selected) + if self.installation_mode == InstallationMode::Custom { + let cdest_label = " Custom destination: "; + let cdest_lstyle = if self.is_focused(SetupFocus::CustomDest) { focus_style } else { muted }; + buf.set_string(inner.x + 5, row_y, cdest_label, cdest_lstyle); + let input_x = inner.x + 5 + label_w; + let input_w = inner.width.saturating_sub(8 + label_w); + if input_w > 0 { + let mut is = Self::copy_input_state(&self.custom_destination, self.is_focused(SetupFocus::CustomDest)); + let inp = Input::new("").prompt("").placeholder("path to install root..."); + StatefulWidget::render(&inp, Rect::new(input_x, row_y, input_w, 1), buf, &mut is); + } + row_y += 1; + } else { + row_y += 1; + } + + // spacing + row_y += 1; + + // ── Configuration section ── + dashed_title( + Rect { x: inner.x, y: row_y, width: inner.width, height: 1 }, + buf, + " Configuration ", + palette::PROCESSING, + palette::PROCESSING, + palette::PROCESSING_DIMMED, + ); + row_y += 1; + + // Bind address row + Self::render_input_row( + inner.x, + &mut row_y, + inner.width, + buf, + " Bind address:", + &self.bind_addr, + self.is_focused(SetupFocus::BindAddr), + label_w, + ); + // Bind port row + Self::render_input_row(inner.x, &mut row_y, inner.width, buf, " Bind port:", &self.bind_port, self.is_focused(SetupFocus::BindPort), label_w); + // Fileserver port row + Self::render_input_row( + inner.x, + &mut row_y, + inner.width, + buf, + " Fileserver port:", + &self.fs_port, + self.is_focused(SetupFocus::FsPort), + label_w, + ); + + // API checkbox + let api_chk = if self.api_enabled { "[x] Enable Web API" } else { "[ ] Enable Web API" }; + let api_style = if self.is_focused(SetupFocus::ApiCheck) { focus_style } else { muted }; + buf.set_string(inner.x + 3, row_y, api_chk, api_style); + row_y += 1; + + // spacing + row_y += 1; + + // ── Buttons ── + let ok_label = " [ OK ] "; + let cancel_label = " [ Cancel ] "; + let btn_w = ok_label.width() as u16 + cancel_label.width() as u16 + 6; + let btn_x = inner.x + (inner.width.saturating_sub(btn_w)) / 2; + + let ok_style = if self.is_focused(SetupFocus::Ok) { + Style::default().fg(palette::WHITE).bg(palette::PROCESSING_HEAT).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette::FG).bg(palette::BG_2).add_modifier(Modifier::BOLD) + }; + let cancel_style = if self.is_focused(SetupFocus::Cancel) { + Style::default().fg(palette::WHITE).bg(palette::PROCESSING_HEAT).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette::FG).bg(palette::BG_2).add_modifier(Modifier::BOLD) + }; + + buf.set_string(btn_x, row_y, ok_label, ok_style); + buf.set_string(btn_x + ok_label.width() as u16 + 4, row_y, cancel_label, cancel_style); + + if let Some(ref err) = self.error_message { + let err_y = row_y.saturating_sub(1); + buf.set_string(inner.x + 2, err_y, err.as_str(), Style::default().fg(palette::ERROR_PEAK)); + } + + // MS-DOS shadow + let buf_area = buf.area(); + let max_x = buf_area.right().saturating_sub(1); + let max_y = buf_area.bottom().saturating_sub(1); + for idx in 0..dlg_w { + let sx = x.saturating_add(2).saturating_add(idx); + let sy = y.saturating_add(dlg_h); + if sx > max_x || sy > max_y { + continue; + } + if let Some(cell) = buf.cell_mut(Position::new(sx, sy)) { + cell.set_bg(palette::SHADOW_BG); + cell.set_fg(palette::SHADOW_FG); + } + } + for offset in 0..2u16 { + for idx in 0..dlg_h { + let sx = x.saturating_add(dlg_w).saturating_add(offset); + let sy = y.saturating_add(idx).saturating_add(1); + if sx > max_x || sy > max_y { + continue; + } + if let Some(cell) = buf.cell_mut(Position::new(sx, sy)) { + cell.set_bg(palette::SHADOW_BG); + cell.set_fg(palette::SHADOW_FG); + } + } + } + } + + #[allow(clippy::too_many_arguments)] + fn render_input_row( + base_x: u16, row_y: &mut u16, inner_width: u16, buf: &mut Buffer, label: &str, state: &InputState, focused: bool, label_w: u16, + ) { + let muted = Style::default().fg(palette::MUTED); + let focus_style = Style::default().fg(palette::ACCENT).add_modifier(Modifier::BOLD); + let lstyle = if focused { focus_style } else { muted }; + let label_padded = format!("{:width$}", label, width = label_w as usize); + buf.set_string(base_x + 3, *row_y, &label_padded, lstyle); + let input_x = base_x + 3 + label_w; + let input_w = inner_width.saturating_sub(label_w + 6); + if input_w > 0 { + let mut is = Self::copy_input_state(state, focused); + let inp = Input::new("").prompt("").placeholder(""); + StatefulWidget::render(&inp, Rect::new(input_x, *row_y, input_w, 1), buf, &mut is); + } + *row_y += 1; + } + + fn copy_input_state(src: &InputState, focused: bool) -> InputState { + let mut is = InputState::new(); + is.set_value(src.value().to_string()); + is.set_focused(focused); + let fc = src.cursor_pos(); + while is.cursor_pos() < fc { + is.move_right(); + } + is + } + + /// Write config file, create directories, return the config path on success. + pub fn write_config(&self) -> Result { + let root = match self.installation_mode { + InstallationMode::SystemWide => std::path::PathBuf::from("/etc/sysinspect"), + InstallationMode::Custom => std::path::PathBuf::from(self.custom_destination.value()), + }; + + // Create root and subdirs + std::fs::create_dir_all(&root).map_err(|e| format!("Cannot create root dir: {e}"))?; + let telemetry_dir = root.join("telemetry"); + let datastore_dir = root.join("datastore"); + std::fs::create_dir_all(&telemetry_dir).map_err(|e| format!("Cannot create telemetry dir: {e}"))?; + std::fs::create_dir_all(&datastore_dir).map_err(|e| format!("Cannot create datastore dir: {e}"))?; + + // Determine config path and pre-create bin/ for self-contained layouts + let is_system = matches!(self.installation_mode, InstallationMode::SystemWide); + let config_dir = if is_system { root.clone() } else { root.join("etc") }; + std::fs::create_dir_all(&config_dir).map_err(|e| format!("Cannot create config dir: {e}"))?; + if !is_system { + let bin_dir = root.join("bin"); + std::fs::create_dir_all(&bin_dir).map_err(|e| format!("Cannot create bin dir: {e}"))?; + let src = std::env::current_exe().map_err(|e| format!("Cannot locate current binary: {e}"))?; + let dest = bin_dir.join("sysinspect"); + std::fs::copy(&src, &dest).map_err(|e| format!("Cannot copy binary to {}: {e}", dest.display()))?; + } + let config_path = config_dir.join("sysinspect.conf"); + + let bind_addr = self.bind_addr.value(); + let bind_port: u32 = self.bind_port.value().parse().map_err(|_| "Invalid bind port".to_string())?; + let fs_port: u32 = self.fs_port.value().parse().map_err(|_| "Invalid fileserver port".to_string())?; + + let partial = format!( + "root: \"{}\"\nbind.ip: \"{}\"\nbind.port: {}\nfileserver.bind.ip: \"{}\"\nfileserver.bind.port: {}\nfileserver.models: []\napi.enabled: {}\n", + root.display(), + bind_addr, + bind_port, + bind_addr, + fs_port, + self.api_enabled, + ); + let master_cfg: MasterConfig = serde_yaml::from_str(&partial).map_err(|e| format!("Cannot construct config: {e}"))?; + let yaml = SysInspectConfig::default().set_master_config(master_cfg).to_yaml(); + std::fs::write(&config_path, yaml).map_err(|e| format!("Cannot write config: {e}"))?; + + Ok(config_path) + } +} diff --git a/src/ui/wgt.rs b/src/ui/wgt.rs index 770464be..38804ec7 100644 --- a/src/ui/wgt.rs +++ b/src/ui/wgt.rs @@ -294,6 +294,9 @@ impl Widget for &SysInspectUX { self._render_right_pane(events_a, buf); // Catch dialogs + if !self.error_alert_visible { + self.setup_wizard.render(area, buf); + } self.dialog_purge(area, buf); self.dialog_exit(area, buf); self.dialog_help(area, buf); @@ -305,5 +308,8 @@ impl Widget for &SysInspectUX { self.dialog_cluster_confirm(area, buf); self.dialog_dsl_browser(area, buf); self.dialog_error(area, buf); + if self.info_alert_visible { + self.dialog_info(area, buf, "Setup Complete", &self.info_alert_message, true); + } } } From a1104b0b6bc6e0d40688a75efdb967ac422f009e Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Wed, 10 Jun 2026 19:23:14 +0200 Subject: [PATCH 03/25] Implement a simple file picker --- src/ui/filepicker.rs | 572 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 572 insertions(+) create mode 100644 src/ui/filepicker.rs diff --git a/src/ui/filepicker.rs b/src/ui/filepicker.rs new file mode 100644 index 00000000..034c22ef --- /dev/null +++ b/src/ui/filepicker.rs @@ -0,0 +1,572 @@ +use super::{ + palette, + title::{self, TitleSegment, TitleStyle}, +}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use ratatui::{ + layout::Position, + prelude::{Buffer, Rect}, + style::{Color, Modifier, Style}, + widgets::{Block, BorderType, Borders, Clear, StatefulWidget, Widget}, +}; +use ratatui_cheese::input::{Input, InputState}; +use ratatui_glamour::color::blend_2d; +use ratatui_glamour::rule::dashed_title; +use std::{ + cell::Cell, + fs, + os::unix::fs::{MetadataExt, PermissionsExt}, + path::PathBuf, +}; +use unicode_width::UnicodeWidthStr; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum PickerMode { + DirectoryPicker, + FilePicker, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum PickerFocus { + Dirs, + Files, +} + +#[derive(Debug)] +struct DirEntry { + name: String, + path: PathBuf, + is_dir: bool, + is_parent: bool, + mode: String, + user: String, + group: String, + mtime: String, + icon: &'static str, +} + +#[derive(Debug)] +pub struct FilePicker { + pub visible: bool, + pub mode: PickerMode, + pub current_path: PathBuf, + pub selected: Option, + entries: Vec, + dirs_end: usize, + dir_cursor: usize, + file_cursor: usize, + dir_scroll: Cell, + file_scroll: Cell, + focus: PickerFocus, + filter_input: InputState, + filter_focus: bool, +} + +impl Default for FilePicker { + fn default() -> Self { + Self { + visible: false, + mode: PickerMode::FilePicker, + current_path: PathBuf::from("."), + selected: None, + entries: Vec::new(), + dirs_end: 0, + dir_cursor: 0, + file_cursor: 0, + dir_scroll: Cell::new(0), + file_scroll: Cell::new(0), + focus: PickerFocus::Dirs, + filter_input: InputState::new(), + filter_focus: false, + } + } +} + +impl FilePicker { + pub fn open(&mut self, path: &PathBuf, mode: PickerMode) { + self.visible = true; + self.mode = mode; + self.current_path = path.clone(); + self.selected = None; + self.dir_cursor = 0; + self.file_cursor = 0; + self.dir_scroll = Cell::new(0); + self.file_scroll = Cell::new(0); + self.focus = PickerFocus::Dirs; + self.filter_input = InputState::new(); + self.filter_focus = false; + self.refresh_entries(); + } + + fn refresh_entries(&mut self) { + self.entries.clear(); + self.dirs_end = 0; + + // Parent entry + if let Some(parent) = self.current_path.parent() { + self.entries.push(DirEntry { + name: "..".into(), + path: parent.to_path_buf(), + is_dir: true, + is_parent: true, + mode: String::new(), + user: String::new(), + group: String::new(), + mtime: String::new(), + icon: "↑", + }); + } + + let filter = self.filter_input.value().to_lowercase(); + + if let Ok(rd) = fs::read_dir(&self.current_path) { + let mut dirs: Vec = Vec::new(); + let mut files: Vec = Vec::new(); + + for entry in rd.flatten() { + let name = entry.file_name().to_string_lossy().to_string(); + let path = entry.path(); + let is_dir = path.is_dir(); + + if !filter.is_empty() && !name.to_lowercase().contains(&filter) { + continue; + } + + let (mode, user, group, mtime) = Self::meta_info_or_unknown(&path); + let icon = Self::file_icon(&path, is_dir); + + let de = DirEntry { name, path, is_dir, is_parent: false, mode, user, group, mtime, icon }; + + if is_dir { + dirs.push(de); + } else { + files.push(de); + } + } + + dirs.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + files.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + + self.entries.append(&mut dirs); + self.dirs_end = self.entries.len(); + self.entries.append(&mut files); + } + + // Clamp cursors + let n_dirs = self.dirs_end.max(1) - 1; // minus parent + let n_files = self.entries.len().saturating_sub(self.dirs_end); + self.dir_cursor = self.dir_cursor.min(n_dirs); + self.file_cursor = self.file_cursor.min(n_files.saturating_sub(1)); + } + + fn meta_info_or_unknown(path: &std::path::Path) -> (String, String, String, String) { + match fs::metadata(path) { + Ok(m) => { + let pm = m.permissions().mode(); + let mode = unix_mode_to_string(pm); + let uid = m.uid(); + let gid = m.gid(); + + // Convert numeric uid/gid to names (best effort) + let user = unsafe { get_owner_name(uid) }; + let group = unsafe { get_group_name(gid) }; + + let mtime = format_mtime(m.modified().ok()); + (mode, user, group, mtime) + } + Err(_) => ("??????????".into(), "???".into(), "???".into(), "??? ?? ??:??".into()), + } + } + + fn file_icon(path: &std::path::Path, is_dir: bool) -> &'static str { + if is_dir { + return "\u{1F4C1}"; // 📁 + } + if let Ok(m) = fs::metadata(path) { + if m.permissions().mode() & 0o111 != 0 { + return "\u{1F4A5}"; // 💥 executable + } + } + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase(); + match ext.as_str() { + "txt" | "log" | "md" | "rs" | "py" | "sh" | "toml" | "yaml" | "yml" | "conf" | "json" | "ini" | "cfg" | "csv" => "\u{1F4C4}", + "png" | "jpg" | "jpeg" | "gif" | "bmp" | "svg" | "webp" => "\u{1F5BC}", + "iso" | "img" | "dmg" => "\u{1F4BF}", + "zip" | "tar" | "gz" | "xz" | "bz2" | "7z" | "rar" => "\u{1F4E6}", + _ => "\u{1F4DC}", // 📜 default + } + } + + pub fn handle_key(&mut self, key: KeyEvent) -> bool { + if !self.visible { + return false; + } + + if self.filter_focus { + match key.code { + KeyCode::Esc => { + self.filter_focus = false; + self.focus = PickerFocus::Dirs; + self.refresh_entries(); + } + KeyCode::Tab | KeyCode::BackTab => { + self.filter_focus = false; + self.refresh_entries(); + } + KeyCode::Enter => { + self.filter_focus = false; + self.refresh_entries(); + } + KeyCode::Backspace => { + self.filter_input.delete_before(); + self.refresh_entries(); + } + KeyCode::Delete => { + self.filter_input.delete_at(); + self.refresh_entries(); + } + KeyCode::Left => { + self.filter_input.move_left(); + } + KeyCode::Right => { + self.filter_input.move_right(); + } + KeyCode::Home => { + self.filter_input.home(); + } + KeyCode::End => { + self.filter_input.end(); + } + KeyCode::Char(c) => { + self.filter_input.insert_char(c); + self.refresh_entries(); + } + _ => {} + } + return true; + } + + match key.code { + KeyCode::Esc => { + self.visible = false; + } + KeyCode::Tab => { + if self.mode == PickerMode::FilePicker && self.entries.len() > self.dirs_end { + self.focus = if self.focus == PickerFocus::Dirs { PickerFocus::Files } else { PickerFocus::Dirs }; + } + } + KeyCode::BackTab => { + if self.mode == PickerMode::FilePicker { + self.focus = if self.focus == PickerFocus::Files { PickerFocus::Dirs } else { PickerFocus::Files }; + } + } + KeyCode::Up => { + match self.focus { + PickerFocus::Dirs => { + self.dir_cursor = self.dir_cursor.saturating_sub(1); + } + PickerFocus::Files => { + self.file_cursor = self.file_cursor.saturating_sub(1); + } + } + } + KeyCode::Down => { + match self.focus { + PickerFocus::Dirs => { + let max = self.dirs_end.saturating_sub(1); + self.dir_cursor = (self.dir_cursor + 1).min(max); + } + PickerFocus::Files => { + let max = self.entries.len().saturating_sub(self.dirs_end).saturating_sub(1); + self.file_cursor = (self.file_cursor + 1).min(max); + } + } + } + KeyCode::Enter => { + let idx = match self.focus { + PickerFocus::Dirs => self.dir_cursor, + PickerFocus::Files => self.dirs_end + self.file_cursor, + }; + + if let Some(entry) = self.entries.get(idx) { + if entry.is_dir { + self.current_path = entry.path.clone(); + self.dir_cursor = 0; + self.file_cursor = 0; + self.refresh_entries(); + } else { + self.selected = Some(entry.path.clone()); + self.visible = false; + } + } + } + KeyCode::Char('/') if !key.modifiers.contains(KeyModifiers::CONTROL) => { + self.filter_focus = true; + self.filter_input = InputState::new(); + } + _ => {} + } + + true + } + + pub fn render(&self, parent: Rect, buf: &mut Buffer) { + if !self.visible { + return; + } + + let dlg_w = (parent.width * 3 / 4).clamp(60, 80); + let dlg_h = (parent.height * 3 / 4).clamp(14, 24); + let x = parent.x + (parent.width.saturating_sub(dlg_w)) / 2; + let y = parent.y + (parent.height.saturating_sub(dlg_h)) / 2; + let canvas = Rect { x, y, width: dlg_w, height: dlg_h }; + + Clear.render(canvas, buf); + + let grad = blend_2d(canvas.width as usize, canvas.height as usize, 10.0, &[palette::GRAY_0, palette::BG_2] as &[Color]); + for row in 0..canvas.height { + for col in 0..canvas.width { + let idx = row as usize * canvas.width as usize + col as usize; + if let Some(cell) = buf.cell_mut(Position::new(canvas.x + col, canvas.y + row)) { + cell.set_bg(grad[idx]); + } + } + } + + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(palette::PROCESSING_GLOW)) + .style(Style::default()); + let inner = block.inner(canvas); + block.render(canvas, buf); + + let title_style = TitleStyle::cyberpunk(palette::PROCESSING_GLOW); + title::overlay_gradient_title( + buf, + canvas, + &title_style, + &[TitleSegment { text: " File Selector ".into(), bg: palette::PROCESSING_BASE, fg: palette::FG }], + ); + + if inner.height < 4 { + return; + } + + let mut row_y = inner.y; + + // ── Filter row ── + let filter_label = if self.filter_focus { " / \u{2192} " } else { " / " }; + let fl_style = if self.filter_focus { + Style::default().fg(palette::ACCENT).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette::MUTED) + }; + buf.set_string(inner.x + 1, row_y, filter_label, fl_style); + + let input_x = inner.x + 5; + let input_w = inner.width.saturating_sub(7); + if input_w > 0 { + let mut is = Self::copy_input_state(&self.filter_input, self.filter_focus); + let inp = Input::new("").prompt("").placeholder("filter..."); + StatefulWidget::render(&inp, Rect::new(input_x, row_y, input_w, 1), buf, &mut is); + } + + let filter_active = !self.filter_input.value().is_empty() || self.filter_focus; + let filter_line = filter_active as u16; + row_y += 1; + + let sections: u16 = if self.mode == PickerMode::FilePicker { 2 } else { 1 }; + let available = inner.height.saturating_sub(1).saturating_sub(row_y.saturating_sub(inner.y)).saturating_sub(filter_line); + let dir_rows = if sections == 2 { available / 2 } else { available }; + + // ── Directories section ── + dashed_title( + Rect { x: inner.x, y: row_y, width: inner.width, height: 1 }, + buf, + " Directories ", + palette::PROCESSING, + palette::PROCESSING, + palette::PROCESSING_DIMMED, + ); + row_y += 1; + + let dir_list = self.entries.iter().take(self.dirs_end).collect::>(); + let dir_end = (row_y + dir_rows).min(inner.y + inner.height); + let dir_area = Rect { x: inner.x + 1, y: row_y, width: inner.width.saturating_sub(1), height: dir_rows.min(dir_end.saturating_sub(row_y)) }; + + self.render_section(dir_area, buf, &dir_list, self.dir_cursor, self.focus == PickerFocus::Dirs, &self.dir_scroll); + row_y = dir_area.y + dir_area.height; + + // ── Files section (FilePicker only) ── + if self.mode == PickerMode::FilePicker && row_y + 1 < inner.y + inner.height { + dashed_title( + Rect { x: inner.x, y: row_y, width: inner.width, height: 1 }, + buf, + " Files ", + palette::PROCESSING, + palette::PROCESSING, + palette::PROCESSING_DIMMED, + ); + row_y += 1; + + let file_list = self.entries.iter().skip(self.dirs_end).collect::>(); + let file_end = (row_y + (available - dir_rows).saturating_sub(1)).min(inner.y + inner.height); + let file_area = Rect { + x: inner.x + 1, + y: row_y, + width: inner.width.saturating_sub(1), + height: (available - dir_rows).saturating_sub(1).min(file_end.saturating_sub(row_y)), + }; + + self.render_section(file_area, buf, &file_list, self.file_cursor, self.focus == PickerFocus::Files, &self.file_scroll); + } + + // MS-DOS shadow + let buf_area = buf.area(); + let max_x = buf_area.right().saturating_sub(1); + let max_y = buf_area.bottom().saturating_sub(1); + for idx in 0..dlg_w { + let sx = x.saturating_add(2).saturating_add(idx); + let sy = y.saturating_add(dlg_h); + if sx > max_x || sy > max_y { continue; } + if let Some(cell) = buf.cell_mut(Position::new(sx, sy)) { + cell.set_bg(palette::SHADOW_BG); + cell.set_fg(palette::SHADOW_FG); + } + } + for offset in 0..2u16 { + for idx in 0..dlg_h { + let sx = x.saturating_add(dlg_w).saturating_add(offset); + let sy = y.saturating_add(idx).saturating_add(1); + if sx > max_x || sy > max_y { continue; } + if let Some(cell) = buf.cell_mut(Position::new(sx, sy)) { + cell.set_bg(palette::SHADOW_BG); + cell.set_fg(palette::SHADOW_FG); + } + } + } + } + + fn render_section(&self, area: Rect, buf: &mut Buffer, entries: &[&DirEntry], cursor: usize, active: bool, scroll: &Cell) { + if area.height == 0 || entries.is_empty() { + return; + } + + let mut s = scroll.get(); + let view_h = area.height as usize; + let total = entries.len(); + let max_scroll = total.saturating_sub(view_h); + + if cursor < s { + s = cursor; + } + if cursor >= s + view_h { + s = cursor.saturating_sub(view_h.saturating_sub(1)); + } + s = s.min(max_scroll); + scroll.set(s); + + let visible = entries.iter().skip(s).take(view_h); + + let muted = Style::default().fg(palette::MUTED); + let hl_style = Style::default().fg(palette::BLACK).bg(palette::HIGHLIGHT); + let muted_hl = Style::default().fg(palette::BG_0).bg(palette::HIGHLIGHT); + + // Calculate field widths for alignment + let longest_name = entries.iter().map(|e| UnicodeWidthStr::width(e.name.as_str()) + 4).max().unwrap_or(10); + + for (i, entry) in visible.enumerate() { + let abs_idx = scroll.get() + i; + let ry = area.y + i as u16; + if ry >= area.y + area.height { + break; + } + + let is_selected = abs_idx == cursor && active; + let row_style = if is_selected { hl_style } else { Style::default().fg(palette::FG) }; + + let icon = if entry.is_parent { "↑ " } else { entry.icon }; + let line = format!(" {icon} {}", entry.name); + buf.set_string(area.x + 1, ry, &line, row_style); + + if !entry.is_parent { + let info = format!(" {} {} {} {}", entry.mode, entry.user, entry.group, entry.mtime); + let info_x = area.x + 3 + longest_name as u16; + if info_x < area.right() { + let info_style = if is_selected { muted_hl } else { muted }; + buf.set_string(info_x, ry, &info, info_style); + } + } + + // Re-paint icon with proper style + if is_selected { + buf.set_string(area.x + 1, ry, &line, row_style); + } + // Re-paint .. "↑" icon + if is_selected { + buf.set_string(area.x + 1, ry, &line, row_style); + } + } + + // Scrollbar + if total > view_h { + let bar_h = ((view_h as f64 / total as f64) * view_h as f64).max(1.0) as usize; + let bar_y = ((s as f64 / total as f64) * (view_h - bar_h) as f64) as usize; + for i in 0..view_h { + let sx = area.right().saturating_sub(1); + let sy = area.y + i as u16; + if i >= bar_y && i < bar_y + bar_h { + buf.set_string(sx, sy, "█", Style::default().fg(palette::PROCESSING_HEAT)); + } else { + buf.set_string(sx, sy, "│", Style::default().fg(palette::MUTED)); + } + } + } + } + + fn copy_input_state(src: &InputState, focused: bool) -> InputState { + let mut is = InputState::new(); + is.set_value(src.value().to_string()); + is.set_focused(focused); + let fc = src.cursor_pos(); + while is.cursor_pos() < fc { + is.move_right(); + } + is + } +} + +fn unix_mode_to_string(mode: u32) -> String { + let mut s = String::with_capacity(10); + s.push(if mode & 0o040000 != 0 { 'd' } else { '-' }); + s.push(if mode & 0o00400 != 0 { 'r' } else { '-' }); + s.push(if mode & 0o00200 != 0 { 'w' } else { '-' }); + s.push(if mode & 0o00100 != 0 { 'x' } else { '-' }); + s.push(if mode & 0o00040 != 0 { 'r' } else { '-' }); + s.push(if mode & 0o00020 != 0 { 'w' } else { '-' }); + s.push(if mode & 0o00010 != 0 { 'x' } else { '-' }); + s.push(if mode & 0o00004 != 0 { 'r' } else { '-' }); + s.push(if mode & 0o00002 != 0 { 'w' } else { '-' }); + s.push(if mode & 0o00001 != 0 { 'x' } else { '-' }); + s +} + +unsafe fn get_owner_name(_uid: u32) -> String { + "user".to_string() +} + +unsafe fn get_group_name(_gid: u32) -> String { + "group".to_string() +} + +fn format_mtime(modified: Option) -> String { + match modified { + Some(t) => { + use chrono::{DateTime, Local}; + let dt: DateTime = t.into(); + dt.format("%b %d %H:%M").to_string() + } + None => "??? ?? ??:??".to_string(), + } +} From 4f2c7fe35d6ac2e9ba32043d761861229e94c424 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Wed, 10 Jun 2026 19:23:21 +0200 Subject: [PATCH 04/25] Add sysmaster binary --- src/ui/filepicker.rs | 67 +++++++++++++++++------------------- src/ui/mod.rs | 81 +++++++++++++++++++++++++++++++++----------- src/ui/setup.rs | 47 +++++++++++++++++++++---- src/ui/wgt.rs | 5 ++- 4 files changed, 137 insertions(+), 63 deletions(-) diff --git a/src/ui/filepicker.rs b/src/ui/filepicker.rs index 034c22ef..2e7eebae 100644 --- a/src/ui/filepicker.rs +++ b/src/ui/filepicker.rs @@ -83,10 +83,10 @@ impl Default for FilePicker { } impl FilePicker { - pub fn open(&mut self, path: &PathBuf, mode: PickerMode) { + pub fn open(&mut self, path: &std::path::Path, mode: PickerMode) { self.visible = true; self.mode = mode; - self.current_path = path.clone(); + self.current_path = path.to_path_buf(); self.selected = None; self.dir_cursor = 0; self.file_cursor = 0; @@ -144,8 +144,8 @@ impl FilePicker { } } - dirs.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); - files.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + dirs.sort_by_key(|a| a.name.to_lowercase()); + files.sort_by_key(|a| a.name.to_lowercase()); self.entries.append(&mut dirs); self.dirs_end = self.entries.len(); @@ -182,10 +182,10 @@ impl FilePicker { if is_dir { return "\u{1F4C1}"; // 📁 } - if let Ok(m) = fs::metadata(path) { - if m.permissions().mode() & 0o111 != 0 { - return "\u{1F4A5}"; // 💥 executable - } + if let Ok(m) = fs::metadata(path) + && m.permissions().mode() & 0o111 != 0 + { + return "\u{1F4A5}"; // 💥 executable } let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase(); match ext.as_str() { @@ -260,28 +260,24 @@ impl FilePicker { self.focus = if self.focus == PickerFocus::Files { PickerFocus::Dirs } else { PickerFocus::Files }; } } - KeyCode::Up => { - match self.focus { - PickerFocus::Dirs => { - self.dir_cursor = self.dir_cursor.saturating_sub(1); - } - PickerFocus::Files => { - self.file_cursor = self.file_cursor.saturating_sub(1); - } + KeyCode::Up => match self.focus { + PickerFocus::Dirs => { + self.dir_cursor = self.dir_cursor.saturating_sub(1); } - } - KeyCode::Down => { - match self.focus { - PickerFocus::Dirs => { - let max = self.dirs_end.saturating_sub(1); - self.dir_cursor = (self.dir_cursor + 1).min(max); - } - PickerFocus::Files => { - let max = self.entries.len().saturating_sub(self.dirs_end).saturating_sub(1); - self.file_cursor = (self.file_cursor + 1).min(max); - } + PickerFocus::Files => { + self.file_cursor = self.file_cursor.saturating_sub(1); } - } + }, + KeyCode::Down => match self.focus { + PickerFocus::Dirs => { + let max = self.dirs_end.saturating_sub(1); + self.dir_cursor = (self.dir_cursor + 1).min(max); + } + PickerFocus::Files => { + let max = self.entries.len().saturating_sub(self.dirs_end).saturating_sub(1); + self.file_cursor = (self.file_cursor + 1).min(max); + } + }, KeyCode::Enter => { let idx = match self.focus { PickerFocus::Dirs => self.dir_cursor, @@ -357,11 +353,8 @@ impl FilePicker { // ── Filter row ── let filter_label = if self.filter_focus { " / \u{2192} " } else { " / " }; - let fl_style = if self.filter_focus { - Style::default().fg(palette::ACCENT).add_modifier(Modifier::BOLD) - } else { - Style::default().fg(palette::MUTED) - }; + let fl_style = + if self.filter_focus { Style::default().fg(palette::ACCENT).add_modifier(Modifier::BOLD) } else { Style::default().fg(palette::MUTED) }; buf.set_string(inner.x + 1, row_y, filter_label, fl_style); let input_x = inner.x + 5; @@ -429,7 +422,9 @@ impl FilePicker { for idx in 0..dlg_w { let sx = x.saturating_add(2).saturating_add(idx); let sy = y.saturating_add(dlg_h); - if sx > max_x || sy > max_y { continue; } + if sx > max_x || sy > max_y { + continue; + } if let Some(cell) = buf.cell_mut(Position::new(sx, sy)) { cell.set_bg(palette::SHADOW_BG); cell.set_fg(palette::SHADOW_FG); @@ -439,7 +434,9 @@ impl FilePicker { for idx in 0..dlg_h { let sx = x.saturating_add(dlg_w).saturating_add(offset); let sy = y.saturating_add(idx).saturating_add(1); - if sx > max_x || sy > max_y { continue; } + if sx > max_x || sy > max_y { + continue; + } if let Some(cell) = buf.cell_mut(Position::new(sx, sy)) { cell.set_bg(palette::SHADOW_BG); cell.set_fg(palette::SHADOW_FG); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 3c91c8b7..e48ce03a 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -39,6 +39,7 @@ use unicode_width::UnicodeWidthStr; mod alert; mod dslbrowser; mod elements; +mod filepicker; mod macts; mod online; mod palette; @@ -187,6 +188,9 @@ pub struct SysInspectUX { // Master setup wizard (first-run) pub setup_wizard: setup::MasterSetupWizard, + // File picker + pub file_picker: filepicker::FilePicker, + // Connection state pub offline: bool, pub last_reconnect_attempt: Instant, @@ -275,6 +279,7 @@ impl Default for SysInspectUX { dsl_browser: dslbrowser::DslBrowser::new(), cfg: MasterConfig::default(), setup_wizard: setup::MasterSetupWizard::default(), + file_picker: filepicker::FilePicker::default(), offline: false, last_reconnect_attempt: Instant::now(), @@ -316,23 +321,29 @@ impl SysInspectUX { while !self.exit { term.draw(|frame| self.draw(frame))?; if self.setup_wizard.ok_pressed { - match self.setup_wizard.write_config() { - Ok(config_path) => { - self.setup_wizard.ok_pressed = false; - self.setup_wizard.visible = false; - let msg = format!( - "Config written to:\n{}\n\nStart the master with:\n sysmaster --start -c {}", - config_path.display(), - config_path.display(), - ); - self.info_alert_visible = true; - self.info_alert_message = msg.clone(); - self.pending_exit = true; - self.pending_exit_message = Some(msg); - } - Err(e) => { - self.setup_wizard.error_message = Some(e); - self.setup_wizard.ok_pressed = false; + if self.setup_wizard.sysmaster_path.value().is_empty() { + self.setup_wizard.error_message = Some("Sys Master binary must be selected.".to_string()); + self.setup_wizard.ok_pressed = false; + self.setup_wizard.focus = setup::SetupFocus::SysMasterPath; + } else { + match self.setup_wizard.write_config() { + Ok(config_path) => { + self.setup_wizard.ok_pressed = false; + self.setup_wizard.visible = false; + let msg = format!( + "Config written to:\n{}\n\nStart the master with:\n sysmaster --start -c {}", + config_path.display(), + config_path.display(), + ); + self.info_alert_visible = true; + self.info_alert_message = msg.clone(); + self.pending_exit = true; + self.pending_exit_message = Some(msg); + } + Err(e) => { + self.setup_wizard.error_message = Some(e); + self.setup_wizard.ok_pressed = false; + } } } } @@ -346,6 +357,26 @@ impl SysInspectUX { return self.run_normal_loop(term); } } + // Launch file picker for sysmaster selection + if self.setup_wizard.launch_file_picker { + self.setup_wizard.launch_file_picker = false; + let start_dir = std::path::Path::new(&self.setup_wizard.sysmaster_path.value()) + .parent() + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()); + self.file_picker.open(&start_dir, filepicker::PickerMode::FilePicker); + } + // File picker result -> back to sysmaster path + if let Some(path) = self.file_picker.selected.take() { + self.setup_wizard.sysmaster_path.set_value(path.to_string_lossy().to_string()); + } + // Status bar for sysmaster path focus + if self.setup_wizard.focus == setup::SetupFocus::SysMasterPath { + self.status_text = Line::from(vec![ + Span::styled(" Enter ", Style::default().fg(palette::FG)), + Span::styled("to browse for sysmaster binary", Style::default().fg(palette::FAINT)), + ]); + } self.on_events_setup()?; } *exit_msg = self.pending_exit_message.take(); @@ -416,7 +447,12 @@ impl SysInspectUX { && let Event::Key(e) = event::read()? && e.kind == KeyEventKind::Press { - if self.setup_wizard.visible && !self.error_alert_visible && !self.exit_alert_visible && !self.info_alert_visible { + if self.setup_wizard.visible + && !self.error_alert_visible + && !self.exit_alert_visible + && !self.info_alert_visible + && !self.file_picker.visible + { self.setup_wizard.handle_key(e); if self.setup_wizard.quit_requested { self.setup_wizard.quit_requested = false; @@ -1679,8 +1715,8 @@ impl SysInspectUX { return; } - // Setup wizard is modal - if self.setup_wizard.visible { + // Setup wizard is modal (but not when file picker is open) + if self.setup_wizard.visible && !self.file_picker.visible { self.setup_wizard.handle_key(e); if self.setup_wizard.quit_requested { self.setup_wizard.quit_requested = false; @@ -1693,6 +1729,11 @@ impl SysInspectUX { return; } + // File picker is modal + if self.file_picker.visible && self.file_picker.handle_key(e) { + return; + } + if self.dsl_browser.visible { self.dsl_browser.handle_key(e.code); if !self.dsl_browser.visible { diff --git a/src/ui/setup.rs b/src/ui/setup.rs index 8f6fdfed..e9bae711 100644 --- a/src/ui/setup.rs +++ b/src/ui/setup.rs @@ -23,6 +23,7 @@ pub enum InstallationMode { #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum SetupFocus { + SysMasterPath, SystemRadio, CustomRadio, CustomDest, @@ -38,6 +39,7 @@ impl SetupFocus { fn next(self) -> Self { use SetupFocus::*; match self { + SysMasterPath => SystemRadio, SystemRadio => CustomRadio, CustomRadio => CustomDest, CustomDest => BindAddr, @@ -46,14 +48,15 @@ impl SetupFocus { FsPort => ApiCheck, ApiCheck => Ok, Ok => Cancel, - Cancel => SystemRadio, + Cancel => SysMasterPath, } } fn prev(self) -> Self { use SetupFocus::*; match self { - SystemRadio => Cancel, + SysMasterPath => Cancel, + SystemRadio => SysMasterPath, CustomRadio => SystemRadio, CustomDest => CustomRadio, BindAddr => CustomDest, @@ -70,6 +73,8 @@ impl SetupFocus { pub struct MasterSetupWizard { pub visible: bool, pub installation_mode: InstallationMode, + pub sysmaster_path: InputState, + pub launch_file_picker: bool, pub custom_destination: InputState, pub bind_addr: InputState, pub bind_port: InputState, @@ -85,6 +90,16 @@ impl Default for MasterSetupWizard { fn default() -> Self { let cwd = std::env::current_dir().map(|p| p.to_string_lossy().to_string()).unwrap_or_default(); + let mut sysmaster_path = InputState::new(); + if let Ok(exe) = std::env::current_exe() + && let Some(exe_dir) = exe.parent() + { + let candidate = exe_dir.join("sysmaster"); + if candidate.exists() && candidate.is_file() { + sysmaster_path.set_value(candidate.to_string_lossy().to_string()); + } + } + let mut custom_dest = InputState::new(); custom_dest.set_value(cwd); @@ -100,12 +115,14 @@ impl Default for MasterSetupWizard { Self { visible: false, installation_mode: InstallationMode::SystemWide, + sysmaster_path, + launch_file_picker: false, custom_destination: custom_dest, bind_addr, bind_port, fs_port, api_enabled: true, - focus: SetupFocus::SystemRadio, + focus: SetupFocus::SysMasterPath, ok_pressed: false, quit_requested: false, error_message: None, @@ -127,6 +144,9 @@ impl MasterSetupWizard { self.focus = self.focus.prev(); } KeyCode::Enter => match self.focus { + SetupFocus::SysMasterPath => { + self.launch_file_picker = true; + } SetupFocus::Ok => { self.ok_pressed = true; } @@ -194,6 +214,7 @@ impl MasterSetupWizard { fn focused_input_mut(&mut self) -> Option<&mut InputState> { match self.focus { + SetupFocus::SysMasterPath => Some(&mut self.sysmaster_path), SetupFocus::CustomDest => Some(&mut self.custom_destination), SetupFocus::BindAddr => Some(&mut self.bind_addr), SetupFocus::BindPort => Some(&mut self.bind_port), @@ -211,7 +232,7 @@ impl MasterSetupWizard { return; } let dlg_w = (parent.width * 3 / 4).clamp(60, 72); - let dlg_h = if self.installation_mode == InstallationMode::Custom { 15u16 } else { 14u16 }; + let dlg_h = if self.installation_mode == InstallationMode::Custom { 16u16 } else { 15u16 }; let x = parent.x + (parent.width.saturating_sub(dlg_w)) / 2; let y = parent.y + (parent.height.saturating_sub(dlg_h)) / 2; let canvas = Rect { x, y, width: dlg_w, height: dlg_h }; @@ -265,6 +286,18 @@ impl MasterSetupWizard { ); row_y += 1; + // SysMaster path row + Self::render_input_row( + inner.x, + &mut row_y, + inner.width, + buf, + " Sys Master:", + &self.sysmaster_path, + self.is_focused(SetupFocus::SysMasterPath), + label_w, + ); + // Radio buttons — each on its own line let sys_checked = self.installation_mode == InstallationMode::SystemWide; let sys_style = if self.is_focused(SetupFocus::SystemRadio) { focus_style } else { muted }; @@ -448,9 +481,9 @@ impl MasterSetupWizard { if !is_system { let bin_dir = root.join("bin"); std::fs::create_dir_all(&bin_dir).map_err(|e| format!("Cannot create bin dir: {e}"))?; - let src = std::env::current_exe().map_err(|e| format!("Cannot locate current binary: {e}"))?; - let dest = bin_dir.join("sysinspect"); - std::fs::copy(&src, &dest).map_err(|e| format!("Cannot copy binary to {}: {e}", dest.display()))?; + let src = std::path::PathBuf::from(self.sysmaster_path.value()); + let dest = bin_dir.join("sysmaster"); + std::fs::copy(&src, &dest).map_err(|e| format!("Cannot copy sysmaster to {}: {e}", dest.display()))?; } let config_path = config_dir.join("sysinspect.conf"); diff --git a/src/ui/wgt.rs b/src/ui/wgt.rs index 38804ec7..a5b57588 100644 --- a/src/ui/wgt.rs +++ b/src/ui/wgt.rs @@ -294,7 +294,10 @@ impl Widget for &SysInspectUX { self._render_right_pane(events_a, buf); // Catch dialogs - if !self.error_alert_visible { + if self.file_picker.visible { + self.file_picker.render(area, buf); + } + if !self.error_alert_visible && !self.file_picker.visible { self.setup_wizard.render(area, buf); } self.dialog_purge(area, buf); From eae63754e7ecee878daf49e1d87ea4183958562f Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Wed, 10 Jun 2026 22:08:19 +0200 Subject: [PATCH 05/25] Implement Master provisioning/setup --- libsysinspect/src/cfg/mod.rs | 10 ++++ src/ui/filepicker.rs | 93 ++++++++++++++++++++++++++---------- src/ui/mod.rs | 34 ++++++++++++- src/ui/setup.rs | 14 ++++-- 4 files changed, 119 insertions(+), 32 deletions(-) diff --git a/libsysinspect/src/cfg/mod.rs b/libsysinspect/src/cfg/mod.rs index d5522af6..d1072a9f 100644 --- a/libsysinspect/src/cfg/mod.rs +++ b/libsysinspect/src/cfg/mod.rs @@ -35,6 +35,16 @@ pub fn select_config_path(p: Option<&str>) -> Result { return Ok(cfp); } + // Self-contained layout: ../etc/sysinspect.conf relative to binary + if let Ok(exe) = std::env::current_exe() + && let Some(grandparent) = exe.parent().and_then(|p| p.parent()) + { + let cfp = grandparent.join("etc").join(APP_CONF); + if cfp.exists() { + return Ok(cfp); + } + } + // Dot-file let cfp = env::var_os("HOME").map(PathBuf::from).or_else(|| { #[cfg(unix)] diff --git a/src/ui/filepicker.rs b/src/ui/filepicker.rs index 2e7eebae..dfca0950 100644 --- a/src/ui/filepicker.rs +++ b/src/ui/filepicker.rs @@ -7,6 +7,7 @@ use ratatui::{ layout::Position, prelude::{Buffer, Rect}, style::{Color, Modifier, Style}, + text::{Line, Span}, widgets::{Block, BorderType, Borders, Clear, StatefulWidget, Widget}, }; use ratatui_cheese::input::{Input, InputState}; @@ -38,10 +39,6 @@ struct DirEntry { path: PathBuf, is_dir: bool, is_parent: bool, - mode: String, - user: String, - group: String, - mtime: String, icon: &'static str, } @@ -104,17 +101,7 @@ impl FilePicker { // Parent entry if let Some(parent) = self.current_path.parent() { - self.entries.push(DirEntry { - name: "..".into(), - path: parent.to_path_buf(), - is_dir: true, - is_parent: true, - mode: String::new(), - user: String::new(), - group: String::new(), - mtime: String::new(), - icon: "↑", - }); + self.entries.push(DirEntry { name: "..".into(), path: parent.to_path_buf(), is_dir: true, is_parent: true, icon: "↑" }); } let filter = self.filter_input.value().to_lowercase(); @@ -132,10 +119,9 @@ impl FilePicker { continue; } - let (mode, user, group, mtime) = Self::meta_info_or_unknown(&path); let icon = Self::file_icon(&path, is_dir); - let de = DirEntry { name, path, is_dir, is_parent: false, mode, user, group, mtime, icon }; + let de = DirEntry { name, path, is_dir, is_parent: false, icon }; if is_dir { dirs.push(de); @@ -278,19 +264,58 @@ impl FilePicker { self.file_cursor = (self.file_cursor + 1).min(max); } }, + KeyCode::PageUp => { + let page = 10usize; + match self.focus { + PickerFocus::Dirs => { + self.dir_cursor = self.dir_cursor.saturating_sub(page); + } + PickerFocus::Files => { + self.file_cursor = self.file_cursor.saturating_sub(page); + } + } + } + KeyCode::PageDown => { + let page = 10usize; + match self.focus { + PickerFocus::Dirs => { + let max = self.dirs_end.saturating_sub(1); + self.dir_cursor = (self.dir_cursor + page).min(max); + } + PickerFocus::Files => { + let max = self.entries.len().saturating_sub(self.dirs_end).saturating_sub(1); + self.file_cursor = (self.file_cursor + page).min(max); + } + } + } KeyCode::Enter => { let idx = match self.focus { PickerFocus::Dirs => self.dir_cursor, PickerFocus::Files => self.dirs_end + self.file_cursor, }; - if let Some(entry) = self.entries.get(idx) { - if entry.is_dir { - self.current_path = entry.path.clone(); - self.dir_cursor = 0; - self.file_cursor = 0; - self.refresh_entries(); - } else { + if let Some(entry) = self.entries.get(idx) + && (entry.is_parent || entry.is_dir) + { + self.current_path = entry.path.clone(); + self.dir_cursor = 0; + self.file_cursor = 0; + self.refresh_entries(); + } + } + KeyCode::Char(' ') => { + if self.mode == PickerMode::DirectoryPicker { + self.selected = Some(self.current_path.clone()); + self.visible = false; + } else { + let idx = match self.focus { + PickerFocus::Dirs => self.dir_cursor, + PickerFocus::Files => self.dirs_end + self.file_cursor, + }; + if let Some(entry) = self.entries.get(idx) + && !entry.is_parent + && !entry.is_dir + { self.selected = Some(entry.path.clone()); self.visible = false; } @@ -306,6 +331,17 @@ impl FilePicker { true } + pub fn status_line(&self) -> Line<'static> { + Line::from(vec![ + Span::styled(" Enter ", Style::default().fg(palette::FG)), + Span::styled("to navigate, ", Style::default().fg(palette::FAINT)), + Span::styled("Space ", Style::default().fg(palette::FG)), + Span::styled("to select, ", Style::default().fg(palette::FAINT)), + Span::styled("Esc ", Style::default().fg(palette::FG)), + Span::styled("to cancel", Style::default().fg(palette::FAINT)), + ]) + } + pub fn render(&self, parent: Rect, buf: &mut Buffer) { if !self.visible { return; @@ -337,12 +373,16 @@ impl FilePicker { let inner = block.inner(canvas); block.render(canvas, buf); + let title_text = match self.mode { + PickerMode::DirectoryPicker => " Directory Selector ", + PickerMode::FilePicker => " File Selector ", + }; let title_style = TitleStyle::cyberpunk(palette::PROCESSING_GLOW); title::overlay_gradient_title( buf, canvas, &title_style, - &[TitleSegment { text: " File Selector ".into(), bg: palette::PROCESSING_BASE, fg: palette::FG }], + &[TitleSegment { text: title_text.into(), bg: palette::PROCESSING_BASE, fg: palette::FG }], ); if inner.height < 4 { @@ -488,7 +528,8 @@ impl FilePicker { buf.set_string(area.x + 1, ry, &line, row_style); if !entry.is_parent { - let info = format!(" {} {} {} {}", entry.mode, entry.user, entry.group, entry.mtime); + let (mode, user, group, mtime) = Self::meta_info_or_unknown(&entry.path); + let info = format!(" {} {} {} {}", mode, user, group, mtime); let info_x = area.x + 3 + longest_name as u16; if info_x < area.right() { let info_style = if is_selected { muted_hl } else { muted }; diff --git a/src/ui/mod.rs b/src/ui/mod.rs index e48ce03a..0e2e840b 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -366,9 +366,26 @@ impl SysInspectUX { .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()); self.file_picker.open(&start_dir, filepicker::PickerMode::FilePicker); } - // File picker result -> back to sysmaster path + // Launch dir picker for custom destination + if self.setup_wizard.launch_dir_picker { + self.setup_wizard.launch_dir_picker = false; + let start_dir = std::path::PathBuf::from(self.setup_wizard.custom_destination.value()); + self.file_picker.open(&start_dir, filepicker::PickerMode::DirectoryPicker); + } + // File/dir picker result if let Some(path) = self.file_picker.selected.take() { - self.setup_wizard.sysmaster_path.set_value(path.to_string_lossy().to_string()); + match self.file_picker.mode { + filepicker::PickerMode::DirectoryPicker => { + self.setup_wizard.custom_destination.set_value(path.to_string_lossy().to_string()); + } + _ => { + self.setup_wizard.sysmaster_path.set_value(path.to_string_lossy().to_string()); + } + } + } + // File picker status bar overrides everything + if self.file_picker.visible { + self.status_text = self.file_picker.status_line(); } // Status bar for sysmaster path focus if self.setup_wizard.focus == setup::SetupFocus::SysMasterPath { @@ -377,6 +394,13 @@ impl SysInspectUX { Span::styled("to browse for sysmaster binary", Style::default().fg(palette::FAINT)), ]); } + // Status bar for custom destination focus + if self.setup_wizard.focus == setup::SetupFocus::CustomDest { + self.status_text = Line::from(vec![ + Span::styled(" Enter ", Style::default().fg(palette::FG)), + Span::styled("to browse for directory", Style::default().fg(palette::FAINT)), + ]); + } self.on_events_setup()?; } *exit_msg = self.pending_exit_message.take(); @@ -387,6 +411,9 @@ impl SysInspectUX { self.last_reconnect_attempt = Instant::now(); while !self.exit { self.sync_main_focus_for_overlays(); + if self.file_picker.visible { + self.status_text = self.file_picker.status_line(); + } term.draw(|frame| self.draw(frame))?; self.on_events()?; if self.offline && self.last_reconnect_attempt.elapsed() >= Duration::from_secs(5) { @@ -404,6 +431,9 @@ impl SysInspectUX { self.no_focus = true; while !self.exit { + if self.file_picker.visible { + self.status_text = self.file_picker.status_line(); + } term.draw(|frame| self.draw(frame))?; // Periodic silent reconnect attempt if self.last_reconnect_attempt.elapsed() >= Duration::from_secs(5) { diff --git a/src/ui/setup.rs b/src/ui/setup.rs index e9bae711..a740a04c 100644 --- a/src/ui/setup.rs +++ b/src/ui/setup.rs @@ -75,6 +75,7 @@ pub struct MasterSetupWizard { pub installation_mode: InstallationMode, pub sysmaster_path: InputState, pub launch_file_picker: bool, + pub launch_dir_picker: bool, pub custom_destination: InputState, pub bind_addr: InputState, pub bind_port: InputState, @@ -91,10 +92,8 @@ impl Default for MasterSetupWizard { let cwd = std::env::current_dir().map(|p| p.to_string_lossy().to_string()).unwrap_or_default(); let mut sysmaster_path = InputState::new(); - if let Ok(exe) = std::env::current_exe() - && let Some(exe_dir) = exe.parent() - { - let candidate = exe_dir.join("sysmaster"); + if let Ok(cwd) = std::env::current_dir() { + let candidate = cwd.join("sysmaster"); if candidate.exists() && candidate.is_file() { sysmaster_path.set_value(candidate.to_string_lossy().to_string()); } @@ -117,6 +116,7 @@ impl Default for MasterSetupWizard { installation_mode: InstallationMode::SystemWide, sysmaster_path, launch_file_picker: false, + launch_dir_picker: false, custom_destination: custom_dest, bind_addr, bind_port, @@ -159,6 +159,9 @@ impl MasterSetupWizard { SetupFocus::CustomRadio => { self.installation_mode = InstallationMode::Custom; } + SetupFocus::CustomDest => { + self.launch_dir_picker = true; + } SetupFocus::ApiCheck => { self.api_enabled = !self.api_enabled; } @@ -484,6 +487,9 @@ impl MasterSetupWizard { let src = std::path::PathBuf::from(self.sysmaster_path.value()); let dest = bin_dir.join("sysmaster"); std::fs::copy(&src, &dest).map_err(|e| format!("Cannot copy sysmaster to {}: {e}", dest.display()))?; + let self_src = std::env::current_exe().map_err(|e| format!("Cannot locate sysinspect binary: {e}"))?; + let self_dest = bin_dir.join("sysinspect"); + std::fs::copy(&self_src, &self_dest).map_err(|e| format!("Cannot copy sysinspect to {}: {e}", self_dest.display()))?; } let config_path = config_dir.join("sysinspect.conf"); From 636170c9785dc6ecd005579ffef52749fa9a4edf Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Wed, 10 Jun 2026 23:24:47 +0200 Subject: [PATCH 06/25] Autofind config --- src/ui/mod.rs | 35 ++++++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 0e2e840b..712b456b 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -330,15 +330,36 @@ impl SysInspectUX { Ok(config_path) => { self.setup_wizard.ok_pressed = false; self.setup_wizard.visible = false; - let msg = format!( - "Config written to:\n{}\n\nStart the master with:\n sysmaster --start -c {}", - config_path.display(), + + // Spawn master in daemon mode (no TUI output pollution) + let master_bin = if self.setup_wizard.installation_mode == setup::InstallationMode::Custom { + std::path::PathBuf::from(self.setup_wizard.custom_destination.value()).join("bin/sysmaster") + } else { + std::path::PathBuf::from(self.setup_wizard.sysmaster_path.value()) + }; + std::process::Command::new(&master_bin) + .arg("--start") + .arg("-c") + .arg(config_path.to_string_lossy().as_ref()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .ok(); + + // Wait for master to come up + for _ in 0..10 { + std::thread::sleep(std::time::Duration::from_millis(500)); + if self.try_reconnect_silent().is_ok() { + return self.run_normal_loop(term); + } + } + + // Not yet up — stay in setup loop, will auto-reconnect + self.info_alert_visible = true; + self.info_alert_message = format!( + "Config written to:\n{}\n\nMaster is starting in the background.\nThe UI will reconnect automatically.", config_path.display(), ); - self.info_alert_visible = true; - self.info_alert_message = msg.clone(); - self.pending_exit = true; - self.pending_exit_message = Some(msg); } Err(e) => { self.setup_wizard.error_message = Some(e); From f0402a93e94519f13430a45e96e629bca6fcbd6c Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Wed, 10 Jun 2026 23:41:17 +0200 Subject: [PATCH 07/25] Add master log viewer --- libsysinspect/src/console/mod.rs | 14 +++ libsysproto/src/query.rs | 3 + src/clifmt.rs | 1 + src/ui/mod.rs | 198 ++++++++++++++++++++++++++++++- src/ui/rawlogs.rs | 137 +++++++++++++++++++++ src/ui/statusbar.rs | 26 ++++ src/ui/wgt.rs | 1 + sysmaster/src/console.rs | 25 +++- sysmaster/src/master.rs | 6 +- 9 files changed, 403 insertions(+), 8 deletions(-) diff --git a/libsysinspect/src/console/mod.rs b/libsysinspect/src/console/mod.rs index ff3616b0..a8f932e1 100644 --- a/libsysinspect/src/console/mod.rs +++ b/libsysinspect/src/console/mod.rs @@ -117,6 +117,15 @@ pub struct ConsoleMinionLogSnapshot { pub truncated: bool, } +/// Raw logfile snapshot for the master's own standard and error logs. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ConsoleMasterLogSnapshot { + pub standard_log: Vec, + pub errors_log: Vec, + pub standard_path: String, + pub errors_path: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum ConsolePayload { @@ -174,6 +183,11 @@ pub enum ConsolePayload { /// Snapshot payload. snapshot: ConsoleMinionLogSnapshot, }, + /// Raw logfile snapshot for the master's own standard and error logs. + MasterLogs { + /// Snapshot payload. + snapshot: ConsoleMasterLogSnapshot, + }, /// Available models discovered by the master. Models { /// One row per discovered model. diff --git a/libsysproto/src/query.rs b/libsysproto/src/query.rs index 77d0b76a..4a9252c6 100644 --- a/libsysproto/src/query.rs +++ b/libsysproto/src/query.rs @@ -61,6 +61,9 @@ pub mod commands { // Force all online minions to reconnect (cluster-wide broadcast) pub const CLUSTER_RECONNECT: &str = "cluster/reconnect"; + + // Read recent raw log snapshot from the master (standard + error logs) + pub const CLUSTER_MASTER_LOGS: &str = "cluster/master/logs"; } /// diff --git a/src/clifmt.rs b/src/clifmt.rs index 9631b1f3..0e969653 100644 --- a/src/clifmt.rs +++ b/src/clifmt.rs @@ -412,5 +412,6 @@ pub fn render_console_payload(payload: &ConsolePayload) -> String { out.join("\n") } ConsolePayload::Models { .. } => String::new(), + ConsolePayload::MasterLogs { snapshot: _ } => String::new(), } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 712b456b..d2bb5956 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -15,8 +15,8 @@ use libsysinspect::{ use libsysproto::query::{ SCHEME_COMMAND, commands::{ - CLUSTER_MINION_HOPSTART, CLUSTER_MINION_INFO, CLUSTER_MINION_LOGS, CLUSTER_MINION_RECONNECT, CLUSTER_MINION_SHUTDOWN, CLUSTER_MODELS, - CLUSTER_ONLINE_MINIONS, CLUSTER_RECONNECT, CLUSTER_SHUTDOWN, CLUSTER_TRAITS_UPDATE, + CLUSTER_MASTER_LOGS, CLUSTER_MINION_HOPSTART, CLUSTER_MINION_INFO, CLUSTER_MINION_LOGS, CLUSTER_MINION_RECONNECT, CLUSTER_MINION_SHUTDOWN, + CLUSTER_MODELS, CLUSTER_ONLINE_MINIONS, CLUSTER_RECONNECT, CLUSTER_SHUTDOWN, CLUSTER_TRAITS_UPDATE, }, }; use ratatui::{ @@ -160,6 +160,16 @@ pub struct SysInspectUX { pub minion_logs_last_fetch: Instant, pub minion_logs_viewport_rows: Cell, + // Master logs popup + pub master_logs_visible: bool, + pub master_logs_tab: usize, + pub master_logs_sections: Vec, + pub master_logs_filter: ratatui_cheese::input::InputState, + pub master_logs_filter_focus: bool, + pub master_logs_polling: bool, + pub master_logs_last_fetch: Instant, + pub master_logs_viewport_rows: Cell, + // Online minions action menu pub minions_menu_visible: bool, pub minions_menu_sel: usize, @@ -262,6 +272,15 @@ impl Default for SysInspectUX { minion_logs_last_fetch: Instant::now(), minion_logs_viewport_rows: Cell::new(0), + master_logs_visible: false, + master_logs_tab: 0, + master_logs_sections: Vec::new(), + master_logs_filter: ratatui_cheese::input::InputState::new(), + master_logs_filter_focus: false, + master_logs_polling: true, + master_logs_last_fetch: Instant::now(), + master_logs_viewport_rows: Cell::new(0), + minions_menu_visible: false, minions_menu_sel: 0, @@ -579,6 +598,9 @@ impl SysInspectUX { Err(_) => self.minion_logs_online = false, } } + if self.master_logs_visible && self.master_logs_polling && self.master_logs_last_fetch.elapsed() >= Duration::from_secs(3) { + let _ = self.load_master_logs(); + } } } } @@ -753,6 +775,10 @@ impl SysInspectUX { return false; } + if self.master_logs_visible { + return self.on_master_logs_popup(e); + } + if self.minion_logs_visible { return self.on_minion_logs_popup(e); } @@ -1582,6 +1608,171 @@ impl SysInspectUX { true } + fn open_master_logs(&mut self) { + self.master_logs_visible = true; + self.master_logs_tab = 0; + self.master_logs_filter = ratatui_cheese::input::InputState::new(); + self.master_logs_filter_focus = false; + self.master_logs_polling = true; + self.master_logs_last_fetch = Instant::now(); + self.status_at_master_logs(); + if let Err(err) = self.load_master_logs() { + self.master_logs_visible = false; + self.error_alert_visible = true; + self.error_alert_message = err.to_string(); + } + } + + fn load_master_logs(&mut self) -> Result<(), SysinspectError> { + let resp = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current() + .block_on(async { call_master_console(&self.cfg, &format!("{SCHEME_COMMAND}{CLUSTER_MASTER_LOGS}"), "*", None, None, None).await }) + })?; + match resp.payload { + ConsolePayload::MasterLogs { snapshot } => { + self.master_logs_sections = vec![ + rawlogs::LogSection { + title: "Standard".into(), + path: snapshot.standard_path, + lines: snapshot.standard_log, + scroll: Cell::new(usize::MAX), + }, + rawlogs::LogSection { + title: "Errors".into(), + path: snapshot.errors_path, + lines: snapshot.errors_log, + scroll: Cell::new(usize::MAX), + }, + ]; + self.master_logs_last_fetch = Instant::now(); + Ok(()) + } + _ => Err(SysinspectError::ProtoError("Unexpected console payload for master logs".to_string())), + } + } + + fn on_master_logs_popup(&mut self, e: event::KeyEvent) -> bool { + if !self.master_logs_visible { + return false; + } + let page = self.master_logs_viewport_rows.get().max(1); + let section = match self.master_logs_sections.get(self.master_logs_tab) { + Some(s) => s, + None => return true, + }; + let rendered = Self::filtered_master_rendered_lines(§ion.lines, &self.master_logs_filter); + let total_rows = rendered.len(); + let max_top = total_rows.saturating_sub(page); + + if self.master_logs_filter_focus { + match e.code { + KeyCode::Esc => { + self.master_logs_filter_focus = false; + } + KeyCode::Tab => { + self.master_logs_filter_focus = false; + } + KeyCode::Backspace => { + self.master_logs_filter.delete_before(); + } + KeyCode::Delete => { + self.master_logs_filter.delete_at(); + } + KeyCode::Left => { + self.master_logs_filter.move_left(); + } + KeyCode::Right => { + self.master_logs_filter.move_right(); + } + KeyCode::Home => { + self.master_logs_filter.home(); + } + KeyCode::End => { + self.master_logs_filter.end(); + } + KeyCode::Char(c) => { + self.master_logs_filter.insert_char(c); + } + _ => {} + } + return true; + } + + match e.code { + KeyCode::Esc => { + self.master_logs_visible = false; + } + KeyCode::Left => { + self.master_logs_tab = self.master_logs_tab.saturating_sub(1); + } + KeyCode::Right => { + if self.master_logs_tab + 1 < self.master_logs_sections.len() { + self.master_logs_tab += 1; + } + } + KeyCode::Tab => { + self.master_logs_filter_focus = true; + } + KeyCode::Up => { + let s = &self.master_logs_sections[self.master_logs_tab]; + let mut scroll = s.scroll.get(); + if scroll == usize::MAX { + scroll = max_top; + } + scroll = scroll.saturating_sub(1); + s.scroll.set(scroll); + } + KeyCode::Down => { + let s = &self.master_logs_sections[self.master_logs_tab]; + let mut scroll = s.scroll.get(); + if scroll == usize::MAX { + return true; + } + scroll = (scroll + 1).min(max_top); + if scroll >= max_top { + scroll = usize::MAX; + } + s.scroll.set(scroll); + } + KeyCode::PageUp => { + let s = &self.master_logs_sections[self.master_logs_tab]; + let mut scroll = s.scroll.get(); + if scroll == usize::MAX { + scroll = max_top; + } + scroll = scroll.saturating_sub(page); + s.scroll.set(scroll); + } + KeyCode::PageDown => { + let s = &self.master_logs_sections[self.master_logs_tab]; + let mut scroll = s.scroll.get(); + if scroll == usize::MAX { + return true; + } + scroll = (scroll + page).min(max_top); + if scroll >= max_top { + scroll = usize::MAX; + } + s.scroll.set(scroll); + } + KeyCode::Char('r') | KeyCode::Char('R') => { + if let Err(err) = self.load_master_logs() { + self.error_alert_visible = true; + self.error_alert_message = err.to_string(); + } + } + KeyCode::Char('/') => { + self.master_logs_filter_focus = true; + } + KeyCode::Char('p') | KeyCode::Char('P') => { + self.master_logs_polling = !self.master_logs_polling; + self.status_at_master_logs(); + } + _ => {} + } + true + } + fn on_tag_popup(&mut self, e: event::KeyEvent) -> bool { if !self.tag_visible { return false; @@ -2018,6 +2209,9 @@ impl SysInspectUX { self.error_alert_message = format!("Failed to load models: {err}"); } }, + KeyCode::Char('m') if !e.modifiers.contains(KeyModifiers::CONTROL) => { + self.open_master_logs(); + } KeyCode::Char('o') if !e.modifiers.contains(KeyModifiers::CONTROL) => match self.fetch_minions() { Ok(rows) if rows.is_empty() => { self.error_alert_visible = true; diff --git a/src/ui/rawlogs.rs b/src/ui/rawlogs.rs index b2cd2671..12cd4a2a 100644 --- a/src/ui/rawlogs.rs +++ b/src/ui/rawlogs.rs @@ -10,6 +10,15 @@ use ratatui::{ widgets::{Block, BorderType, Borders, Clear, Paragraph, Scrollbar, ScrollbarState, Widget}, }; use ratatui_cheese::input::{Input, InputState, InputStyles}; +use std::cell::Cell; + +#[derive(Debug)] +pub struct LogSection { + pub title: String, + pub path: String, + pub lines: Vec, + pub scroll: Cell, +} impl SysInspectUX { pub fn dialog_minion_logs(&self, parent: Rect, buf: &mut Buffer) { @@ -131,6 +140,134 @@ impl SysInspectUX { } } + pub fn dialog_master_logs(&self, parent: Rect, buf: &mut Buffer) { + if !self.master_logs_visible { + return; + } + + let section = match self.master_logs_sections.get(self.master_logs_tab) { + Some(s) => s, + None => return, + }; + + let border = palette::PROCESSING_GLOW; + let title_style = TitleStyle::cyberpunk(border); + let bg = palette::BG_2; + + let mut segments = vec![ + TitleSegment { text: " Master ".into(), bg: palette::PROCESSING_GLOW, fg: palette::FG }, + TitleSegment { text: format!(" {} ", section.title), bg: palette::PROCESSING_HEAT, fg: palette::SUCCESS }, + TitleSegment { text: format!(" {} ", section.path), bg: palette::PROCESSING_PEAK, fg: palette::FG }, + ]; + if self.master_logs_polling { + segments.push(TitleSegment { text: " \u{27F3} ".into(), bg: palette::PROCESSING, fg: palette::BG_1 }); + } + let min_width = title::ensure_inner_width(60, &title_style, &segments).saturating_add(2); + let width = parent.width.saturating_sub(6).clamp(min_width, 140); + let height = parent.height.saturating_sub(4).clamp(10, parent.height.saturating_sub(2)); + let x = parent.x + (parent.width.saturating_sub(width)) / 2; + let y = parent.y + (parent.height.saturating_sub(height)) / 2; + let canvas = Rect { x, y, width, height }; + + Clear.render(canvas, buf); + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(border)) + .style(Style::default().bg(bg)); + let inner = block.inner(canvas); + block.render(canvas, buf); + + title::overlay_gradient_title(buf, canvas, &title_style, &segments); + + if inner.height < 5 { + return; + } + + let [filter_area, path_area, content_area]: [Rect; 3] = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Length(1), Constraint::Min(0)]) + .split(inner) + .as_ref() + .try_into() + .unwrap(); + + Self::_render_logs_filter(filter_area, buf, self.master_logs_filter_focus, &self.master_logs_filter); + + let path_line = Line::styled(format!(" {} ", section.path), Style::default().fg(palette::MUTED).bg(bg).add_modifier(Modifier::DIM)); + Widget::render(Paragraph::new(path_line), path_area, buf); + + let filter_val = self.master_logs_filter.value().to_lowercase(); + let filtered_lines: Vec<&String> = if filter_val.is_empty() { + section.lines.iter().collect() + } else { + section.lines.iter().filter(|l| l.to_lowercase().contains(&filter_val)).collect() + }; + + let text_area = Rect { x: content_area.x, y: content_area.y, width: content_area.width.saturating_sub(1), height: content_area.height }; + self.master_logs_viewport_rows.set(text_area.height as usize); + + let rendered_lines = Self::filtered_master_rendered_lines(§ion.lines, &self.master_logs_filter); + let text_h = text_area.height as usize; + let max_top = rendered_lines.len().saturating_sub(text_h); + let scroll = section.scroll.get(); + let start = if scroll == usize::MAX { max_top } else { scroll.min(max_top) }; + let end = (start + text_h).min(rendered_lines.len()); + if filtered_lines.is_empty() { + let msg = if section.lines.is_empty() { "(no log lines)" } else { "(no matches)" }; + Widget::render(Paragraph::new(msg).style(Style::default().fg(palette::FG).bg(bg)), text_area, buf); + } else { + let visible: Vec = rendered_lines[start..end].to_vec(); + Widget::render(Paragraph::new(visible).style(Style::default().bg(bg)), text_area, buf); + } + + let scroll_area = Rect { x: content_area.right().saturating_sub(1), y: content_area.y, width: 1, height: content_area.height }; + let mut scrollbar = ScrollbarState::default().content_length(rendered_lines.len().max(1)).position(start); + Scrollbar::default() + .begin_symbol(None) + .end_symbol(None) + .track_symbol(Some("\u{28FF}")) + .thumb_symbol("█") + .track_style(Style::default().bg(bg)) + .thumb_style(Style::default().fg(palette::GRAY_1)) + .render(scroll_area, buf, &mut scrollbar); + + let buf_area = buf.area(); + let max_x = buf_area.right().saturating_sub(1); + let max_y = buf_area.bottom().saturating_sub(1); + for idx in 0..width { + let sx = x.saturating_add(2).saturating_add(idx); + let sy = y.saturating_add(height); + if sx > max_x || sy > max_y { + continue; + } + if let Some(cell) = buf.cell_mut(Position::new(sx, sy)) { + cell.set_bg(palette::SHADOW_BG); + cell.set_fg(palette::SHADOW_FG); + } + } + for offset in 0..2u16 { + for idx in 0..height { + let sx = x.saturating_add(width).saturating_add(offset); + let sy = y.saturating_add(idx).saturating_add(1); + if sx > max_x || sy > max_y { + continue; + } + if let Some(cell) = buf.cell_mut(Position::new(sx, sy)) { + cell.set_bg(palette::SHADOW_BG); + cell.set_fg(palette::SHADOW_FG); + } + } + } + } + + pub(crate) fn filtered_master_rendered_lines(lines: &[String], filter: &InputState) -> Vec> { + let fv = filter.value().to_lowercase(); + let filtered: Vec<&String> = + if fv.is_empty() { lines.iter().collect() } else { lines.iter().filter(|l| l.to_lowercase().contains(&fv)).collect() }; + filtered.iter().flat_map(|s| render_log_line(s)).collect() + } + fn _render_logs_filter(area: Rect, buf: &mut Buffer, focused: bool, filter_state: &InputState) { buf.set_string(area.x, area.y, "Filter: ", Style::default().fg(palette::FG)); diff --git a/src/ui/statusbar.rs b/src/ui/statusbar.rs index 93e0a9a5..1ea7d31f 100644 --- a/src/ui/statusbar.rs +++ b/src/ui/statusbar.rs @@ -140,6 +140,32 @@ impl SysInspectUX { self.status_text = Line::from(spans); } + pub(crate) fn status_at_master_logs(&mut self) { + let key = |s| Span::styled(s, Style::default().fg(palette::FG)); + let desc = |s| Span::styled(s, Style::default().fg(palette::FAINT)); + let mut spans = vec![ + key("\u{2190}\u{2192} "), + desc("switch tab, "), + key("\u{2191}\u{2193} "), + desc("scroll, "), + key("PgUp/PgDn "), + desc("skip, "), + key("Tab "), + desc("filter, "), + key("/ "), + desc("filter, "), + key("P "), + desc(if self.master_logs_polling { "pause, " } else { "resume, " }), + ]; + if !self.master_logs_polling { + spans.push(key("R ")); + spans.push(desc("refresh, ")); + } + spans.push(key("Esc ")); + spans.push(desc("close")); + self.status_text = Line::from(spans); + } + pub(crate) fn status_at_query_composer(&mut self) { self.status_text = Line::from(vec![ Span::styled(" Tab ", Style::default().fg(palette::FG)), diff --git a/src/ui/wgt.rs b/src/ui/wgt.rs index a5b57588..c4351051 100644 --- a/src/ui/wgt.rs +++ b/src/ui/wgt.rs @@ -307,6 +307,7 @@ impl Widget for &SysInspectUX { self.minion_actions_menu(area, buf); self.minion_traits(area, buf); self.dialog_minion_logs(area, buf); + self.dialog_master_logs(area, buf); self.dialog_trait_tag(area, buf); self.dialog_cluster_confirm(area, buf); self.dialog_dsl_browser(area, buf); diff --git a/sysmaster/src/console.rs b/sysmaster/src/console.rs index 6b3e6c57..642741f0 100644 --- a/sysmaster/src/console.rs +++ b/sysmaster/src/console.rs @@ -12,9 +12,9 @@ use libmodpak::{SysInspectModPak, compare_versions}; use libsysinspect::{ cfg::mmconf::MinionConfig, console::{ - ConsoleEnvelope, ConsoleMinionInfoRow, ConsoleMinionLogRequest, ConsoleMinionLogSnapshot, ConsoleModelRow, ConsoleOnlineMinionRow, - ConsolePayload, ConsoleQuery, ConsoleResponse, ConsoleSealed, ConsoleTransportStatusRow, MinionCommandReply, authorised_console_client, - load_master_private_key, + ConsoleEnvelope, ConsoleMasterLogSnapshot, ConsoleMinionInfoRow, ConsoleMinionLogRequest, ConsoleMinionLogSnapshot, ConsoleModelRow, + ConsoleOnlineMinionRow, ConsolePayload, ConsoleQuery, ConsoleResponse, ConsoleSealed, ConsoleTransportStatusRow, MinionCommandReply, + authorised_console_client, load_master_private_key, }, context::get_context, mdescr::catalog::ModelCatalog, @@ -453,6 +453,18 @@ impl SysMaster { Ok(snapshot) } + async fn master_log_snapshot(master: Arc>) -> Result { + let guard = master.lock().await; + let std = std::fs::read_to_string(guard.cfg.logfile_std()).unwrap_or_default(); + let err = std::fs::read_to_string(guard.cfg.logfile_err()).unwrap_or_default(); + Ok(ConsoleMasterLogSnapshot { + standard_log: std.lines().map(|s| s.to_string()).collect(), + errors_log: err.lines().map(|s| s.to_string()).collect(), + standard_path: guard.cfg.logfile_std().display().to_string(), + errors_path: guard.cfg.logfile_err().display().to_string(), + }) + } + /// Remove a minion from registry and key storage and prepare the matching console reply. /// /// When a command message can still be constructed for the target minion it @@ -693,6 +705,13 @@ impl SysMaster { }; } + if query.model.eq(&format!("{SCHEME_COMMAND}{CLUSTER_MASTER_LOGS}")) { + return match Self::master_log_snapshot(Arc::clone(&master)).await { + Ok(snapshot) => ConsoleResponse::ok(ConsolePayload::MasterLogs { snapshot }), + Err(err) => ConsoleResponse::err(format!("Unable to get master logs: {err}")), + }; + } + if query.model.eq(&format!("{SCHEME_COMMAND}{CLUSTER_TRANSPORT_STATUS}")) { return match TransportStatusConsoleRequest::from_context(&query.context) { Ok(request) => match master.lock().await.transport_status_data(&request, &query.query, &query.traits, &query.mid).await { diff --git a/sysmaster/src/master.rs b/sysmaster/src/master.rs index bb0e9e4f..6217c5aa 100644 --- a/sysmaster/src/master.rs +++ b/sysmaster/src/master.rs @@ -39,9 +39,9 @@ use libsysproto::{ query::{ SCHEME_COMMAND, commands::{ - CLUSTER_CMDB_UPSERT, CLUSTER_HOPSTART, CLUSTER_MINION_HOPSTART, CLUSTER_MINION_INFO, CLUSTER_MINION_LOGS, CLUSTER_MINION_RECONNECT, - CLUSTER_MINION_SHUTDOWN, CLUSTER_MODELS, CLUSTER_ONLINE_MINIONS, CLUSTER_PROFILE, CLUSTER_REMOVE_MINION, CLUSTER_ROTATE, - CLUSTER_TRAITS_UPDATE, CLUSTER_TRANSPORT_STATUS, + CLUSTER_CMDB_UPSERT, CLUSTER_HOPSTART, CLUSTER_MASTER_LOGS, CLUSTER_MINION_HOPSTART, CLUSTER_MINION_INFO, CLUSTER_MINION_LOGS, + CLUSTER_MINION_RECONNECT, CLUSTER_MINION_SHUTDOWN, CLUSTER_MODELS, CLUSTER_ONLINE_MINIONS, CLUSTER_PROFILE, CLUSTER_REMOVE_MINION, + CLUSTER_ROTATE, CLUSTER_TRAITS_UPDATE, CLUSTER_TRANSPORT_STATUS, }, }, replay::{ReplayIdentity, replay_identity_for_master_command_cycle, replay_identity_from_minion_message}, From 27b9b16c2aa2132e8d72539b52a72bd6eba2b98f Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Thu, 11 Jun 2026 01:36:29 +0200 Subject: [PATCH 08/25] Add physical logs view, start/stop/restart master --- libsysinspect/src/cfg/mmconf.rs | 2 +- src/ui/alert.rs | 33 +++- src/ui/filepicker.rs | 43 ++++- src/ui/macts.rs | 250 ++++++++++++++++----------- src/ui/mod.rs | 290 ++++++++++++++++++++++++++++---- src/ui/setup.rs | 39 ++++- src/ui/statusbar.rs | 7 + src/ui/wgt.rs | 2 + 8 files changed, 520 insertions(+), 146 deletions(-) diff --git a/libsysinspect/src/cfg/mmconf.rs b/libsysinspect/src/cfg/mmconf.rs index 093c3ebf..4d1150da 100644 --- a/libsysinspect/src/cfg/mmconf.rs +++ b/libsysinspect/src/cfg/mmconf.rs @@ -1530,7 +1530,7 @@ impl MasterConfig { /// Return errors logfile in daemon mode pub fn logfile_err(&self) -> PathBuf { - if let Some(lfn) = &self.log_main { + if let Some(lfn) = &self.log_err { return PathBuf::from(lfn); } diff --git a/src/ui/alert.rs b/src/ui/alert.rs index 480ac7e0..b7765500 100644 --- a/src/ui/alert.rs +++ b/src/ui/alert.rs @@ -114,7 +114,7 @@ impl SysInspectUX { parent, buf, Some("Help"), - "\"c\" - call composer\n\"h\" - show this help\n\"o\" - registered minions popup\n\"p\" - purge all records\n\"q\" - quit the UI\n", + "\"c\" - call composer\n\"h\" - show this help\n\"m\" - master operations\n\"o\" - registered minions popup\n\"p\" - purge all records\n\"q\" - quit the UI\n", None, Alignment::Left, AlertResult::Close, @@ -200,6 +200,37 @@ impl SysInspectUX { ); } + pub fn dialog_master_confirm(&self, parent: Rect, buf: &mut Buffer) { + if !self.master_confirm_visible { + return; + } + let text = match self.master_confirm_action { + 1 => "Start the master in daemon mode?", + 2 => "Restart the master?\n\nThis will stop the daemon and start it again.", + 3 => "Stop the master?\n\nThis will terminate the daemon process.", + _ => return, + }; + Self::_popup_ex( + parent, + buf, + Some("Master Operation"), + text, + None, + Alignment::Center, + self.master_confirm_choice.clone(), + AlertButtons::YesNo, + Some(50), + Some(palette::PROCESSING_PEAK), + None, + None, + Some(palette::FG), + None, + None, + None, + Some((10.0, &[palette::GRAY_0, palette::BG_2] as &[Color])), + ); + } + /// Draws a button in MS-DOS style (no shadow) pub(crate) fn format_button(label: &str) -> String { let trimmed: String = if label.chars().count() > 10 { label.chars().take(10).collect() } else { label.to_string() }; diff --git a/src/ui/filepicker.rs b/src/ui/filepicker.rs index dfca0950..833a482f 100644 --- a/src/ui/filepicker.rs +++ b/src/ui/filepicker.rs @@ -510,8 +510,15 @@ impl FilePicker { let hl_style = Style::default().fg(palette::BLACK).bg(palette::HIGHLIGHT); let muted_hl = Style::default().fg(palette::BG_0).bg(palette::HIGHLIGHT); - // Calculate field widths for alignment - let longest_name = entries.iter().map(|e| UnicodeWidthStr::width(e.name.as_str()) + 4).max().unwrap_or(10); + // Calculate field widths for alignment (includes icon + spaces) + let longest_name = entries + .iter() + .map(|e| { + let icon = if e.is_parent { "↑ " } else { e.icon }; + UnicodeWidthStr::width(format!(" {icon} {}", e.name).as_str()) + }) + .max() + .unwrap_or(10); for (i, entry) in visible.enumerate() { let abs_idx = scroll.get() + i; @@ -525,25 +532,33 @@ impl FilePicker { let icon = if entry.is_parent { "↑ " } else { entry.icon }; let line = format!(" {icon} {}", entry.name); - buf.set_string(area.x + 1, ry, &line, row_style); if !entry.is_parent { let (mode, user, group, mtime) = Self::meta_info_or_unknown(&entry.path); let info = format!(" {} {} {} {}", mode, user, group, mtime); let info_x = area.x + 3 + longest_name as u16; + let name_end = info_x.saturating_sub(1); // leave gap before info + let max_name_w = name_end.saturating_sub(area.x + 1); + let name_trimmed = truncate_to_width(&line, max_name_w); + buf.set_string(area.x + 1, ry, &name_trimmed, row_style); if info_x < area.right() { let info_style = if is_selected { muted_hl } else { muted }; buf.set_string(info_x, ry, &info, info_style); } - } - - // Re-paint icon with proper style - if is_selected { + } else { buf.set_string(area.x + 1, ry, &line, row_style); } - // Re-paint .. "↑" icon + + // Re-paint with highlight if selected if is_selected { - buf.set_string(area.x + 1, ry, &line, row_style); + if !entry.is_parent { + let name_end = (area.x + 3 + longest_name as u16).saturating_sub(1); + let max_name_w = name_end.saturating_sub(area.x + 1); + let name_trimmed = truncate_to_width(&line, max_name_w); + buf.set_string(area.x + 1, ry, &name_trimmed, row_style); + } else { + buf.set_string(area.x + 1, ry, &line, row_style); + } } } @@ -608,3 +623,13 @@ fn format_mtime(modified: Option) -> String { None => "??? ?? ??:??".to_string(), } } + +fn truncate_to_width(s: &str, max_w: u16) -> String { + let mut w: u16 = 0; + s.chars() + .take_while(|c| { + w += unicode_width::UnicodeWidthChar::width(*c).unwrap_or(0) as u16; + w <= max_w + }) + .collect() +} diff --git a/src/ui/macts.rs b/src/ui/macts.rs index 52c5f200..567915d6 100644 --- a/src/ui/macts.rs +++ b/src/ui/macts.rs @@ -29,10 +29,141 @@ const MENU_SECTIONS: &[MenuSection] = &[ }, ]; +const MASTER_MENU_SECTIONS: &[MenuSection] = &[ + MenuSection { title: "Operations", items: &[("View master logs online", ""), ("View local logs", ""), ("Register a minion", "")] }, + MenuSection { title: "System", items: &[("Start", ""), ("Stop", ""), ("Restart", "")] }, +]; + pub(crate) fn total_menu_items() -> usize { MENU_SECTIONS.iter().map(|s| s.items.len()).sum() } +pub(crate) fn total_master_menu_items() -> usize { + MASTER_MENU_SECTIONS.iter().map(|s| s.items.len()).sum() +} + +#[allow(clippy::too_many_arguments)] +fn render_menu_popup( + parent: Rect, buf: &mut Buffer, sections: &[MenuSection], sel: usize, title_segments: &[TitleSegment], title_style: &TitleStyle, + max_item_w: usize, disabled: &[bool], +) { + let inner_w = title::ensure_inner_width(max_item_w as u16, title_style, title_segments); + + let section_headers = sections.len() as u16; + let item_rows: u16 = sections.iter().map(|s| s.items.len() as u16).sum(); + let inner_h = section_headers + item_rows + 2; + + let w = (inner_w + 2).min(parent.width.saturating_sub(8)).max(20); + let h = (inner_h + 2).min(parent.height.saturating_sub(6)).max(5); + let x = parent.x + (parent.width.saturating_sub(w)) / 2; + let y = parent.y + (parent.height.saturating_sub(h)) / 2; + let canvas = Rect { x, y, width: w, height: h }; + + Clear.render(canvas, buf); + + let grad_colors = blend_2d(canvas.width as usize, canvas.height as usize, 10.0, &[palette::GRAY_0, palette::BG_2] as &[ratatui::style::Color]); + for row in 0..canvas.height { + for col in 0..canvas.width { + let idx = row as usize * canvas.width as usize + col as usize; + if let Some(cell) = buf.cell_mut(Position::new(canvas.x + col, canvas.y + row)) { + cell.set_bg(grad_colors[idx]); + } + } + } + + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(palette::PROCESSING_GLOW)) + .style(Style::default()); + let inner = block.inner(canvas); + block.render(canvas, buf); + + title::overlay_gradient_title(buf, canvas, title_style, title_segments); + + let hint_style = Style::default().fg(palette::PRIMARY); + + let mut row_y = inner.y; + let mut flat_idx: usize = 0; + + for (si, section) in sections.iter().enumerate() { + if row_y >= inner.bottom() { + break; + } + if si > 0 && row_y < inner.bottom() { + row_y += 1; + } + dashed_title( + Rect { x: inner.x, y: row_y, width: inner.width, height: 1 }, + buf, + section.title, + palette::PROCESSING, + palette::PRIMARY, + palette::PROCESSING_DIMMED, + ); + row_y += 1; + + for &(label, key) in section.items { + if row_y >= inner.bottom() { + break; + } + let selected = flat_idx == sel; + let is_disabled = disabled.get(flat_idx).copied().unwrap_or(false); + let item_style = if is_disabled { + Style::default().fg(palette::MUTED) + } else if selected { + Style::default().fg(palette::BLACK).bg(palette::HIGHLIGHT) + } else { + Style::default().fg(palette::FG) + }; + + let hint = key; + let padding = (inner.width as usize).saturating_sub(UnicodeWidthStr::width(label) + 1 + UnicodeWidthStr::width(hint)).saturating_sub(2); + let line = format!(" {label}{}{hint} ", " ".repeat(padding)); + buf.set_string(inner.x, row_y, &line, item_style); + + // Re-paint just the key hint with its own style on top + if !hint.is_empty() { + let hint_x = inner.x + (inner.width.saturating_sub(UnicodeWidthStr::width(hint) as u16 + 2)); + let hint_sel_style = if selected && !is_disabled { Style::default().fg(palette::BG_0).bg(palette::HIGHLIGHT) } else { hint_style }; + buf.set_string(hint_x, row_y, hint, hint_sel_style); + } + + row_y += 1; + flat_idx += 1; + } + } + + let buf_area = buf.area(); + let max_x = buf_area.right().saturating_sub(1); + let max_y = buf_area.bottom().saturating_sub(1); + + for idx in 0..w { + let sx = x.saturating_add(2).saturating_add(idx); + let sy = y.saturating_add(h); + if sx > max_x || sy > max_y { + continue; + } + if let Some(cell) = buf.cell_mut(Position::new(sx, sy)) { + cell.set_bg(palette::SHADOW_BG); + cell.set_fg(palette::SHADOW_FG); + } + } + for offset in 0..2u16 { + for idx in 0..h { + let sx = x.saturating_add(w).saturating_add(offset); + let sy = y.saturating_add(idx).saturating_add(1); + if sx > max_x || sy > max_y { + continue; + } + if let Some(cell) = buf.cell_mut(Position::new(sx, sy)) { + cell.set_bg(palette::SHADOW_BG); + cell.set_fg(palette::SHADOW_FG); + } + } + } +} + impl SysInspectUX { pub fn minion_actions_menu(&self, parent: Rect, buf: &mut Buffer) { if !self.minions_menu_visible { @@ -67,113 +198,28 @@ impl SysInspectUX { } else { segments.push(TitleSegment { text: format!(" {host} "), bg: palette::PROCESSING_HEAT, fg: palette::SUCCESS_PEAK }); } - let inner_w = title::ensure_inner_width(max_item_w as u16, &title_style, &segments); - - let section_headers = MENU_SECTIONS.len() as u16; - let item_rows = total_menu_items() as u16; - let inner_h = section_headers + item_rows + 2; - - let w = (inner_w + 2).min(parent.width.saturating_sub(8)).max(20); - let h = (inner_h + 2).min(parent.height.saturating_sub(6)).max(5); - let x = parent.x + (parent.width.saturating_sub(w)) / 2; - let y = parent.y + (parent.height.saturating_sub(h)) / 2; - let canvas = Rect { x, y, width: w, height: h }; - - Clear.render(canvas, buf); - - let grad_colors = - blend_2d(canvas.width as usize, canvas.height as usize, 10.0, &[palette::GRAY_0, palette::BG_2] as &[ratatui::style::Color]); - for row in 0..canvas.height { - for col in 0..canvas.width { - let idx = row as usize * canvas.width as usize + col as usize; - if let Some(cell) = buf.cell_mut(Position::new(canvas.x + col, canvas.y + row)) { - cell.set_bg(grad_colors[idx]); - } - } - } - - let block = Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(palette::PROCESSING_GLOW)) - .style(Style::default()); - let inner = block.inner(canvas); - block.render(canvas, buf); - - title::overlay_gradient_title(buf, canvas, &title_style, &segments); - - let hint_style = Style::default().fg(palette::PRIMARY); - let mut row_y = inner.y; - let mut flat_idx: usize = 0; - - for (si, section) in MENU_SECTIONS.iter().enumerate() { - if row_y >= inner.bottom() { - break; - } - if si > 0 && row_y < inner.bottom() { - row_y += 1; - } - dashed_title( - Rect { x: inner.x, y: row_y, width: inner.width, height: 1 }, - buf, - section.title, - palette::PROCESSING, - palette::PRIMARY, - palette::PROCESSING_DIMMED, - ); - row_y += 1; - - for &(label, key) in section.items { - if row_y >= inner.bottom() { - break; - } - let selected = flat_idx == self.minions_menu_sel; - let item_style = if selected { Style::default().fg(palette::BLACK).bg(palette::HIGHLIGHT) } else { Style::default().fg(palette::FG) }; + render_menu_popup(parent, buf, MENU_SECTIONS, self.minions_menu_sel, &segments, &title_style, max_item_w, &[]); + } - let hint = key; - let padding = - (inner.width as usize).saturating_sub(UnicodeWidthStr::width(label) + 1 + UnicodeWidthStr::width(hint)).saturating_sub(2); - let line = format!(" {label}{}{hint} ", " ".repeat(padding)); - buf.set_string(inner.x, row_y, &line, item_style); + pub fn master_actions_menu(&self, parent: Rect, buf: &mut Buffer) { + if !self.master_menu_visible { + return; + } - // Re-paint just the key hint with its own style on top - let hint_x = inner.x + (inner.width.saturating_sub(UnicodeWidthStr::width(hint) as u16 + 2)); - let hint_sel_style = if selected { Style::default().fg(palette::BG_0).bg(palette::HIGHLIGHT) } else { hint_style }; - buf.set_string(hint_x, row_y, hint, hint_sel_style); + let max_label_w = + MASTER_MENU_SECTIONS.iter().flat_map(|s| s.items.iter()).map(|(label, _)| UnicodeWidthStr::width(*label)).max().unwrap_or(10); + let max_item_w = max_label_w + 20; - row_y += 1; - flat_idx += 1; - } - } + let title_style = TitleStyle::cyberpunk(palette::PROCESSING_GLOW); + let segments = vec![ + TitleSegment { text: " Master ".into(), bg: palette::PROCESSING_GLOW, fg: palette::FG }, + TitleSegment { text: " Operations ".into(), bg: palette::PROCESSING_HEAT, fg: palette::FG }, + ]; - let buf_area = buf.area(); - let max_x = buf_area.right().saturating_sub(1); - let max_y = buf_area.bottom().saturating_sub(1); + let local_logs_available = self.cfg.logfile_std().exists() || self.cfg.logfile_err().exists(); + let disabled = [!local_logs_available, false, false, false, false, false]; - for idx in 0..w { - let sx = x.saturating_add(2).saturating_add(idx); - let sy = y.saturating_add(h); - if sx > max_x || sy > max_y { - continue; - } - if let Some(cell) = buf.cell_mut(Position::new(sx, sy)) { - cell.set_bg(palette::SHADOW_BG); - cell.set_fg(palette::SHADOW_FG); - } - } - for offset in 0..2u16 { - for idx in 0..h { - let sx = x.saturating_add(w).saturating_add(offset); - let sy = y.saturating_add(idx).saturating_add(1); - if sx > max_x || sy > max_y { - continue; - } - if let Some(cell) = buf.cell_mut(Position::new(sx, sy)) { - cell.set_bg(palette::SHADOW_BG); - cell.set_fg(palette::SHADOW_FG); - } - } - } + render_menu_popup(parent, buf, MASTER_MENU_SECTIONS, self.master_menu_sel, &segments, &title_style, max_item_w, &disabled); } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index d2bb5956..9aeef2ed 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -30,6 +30,7 @@ use ratatui_cheese::tree::TreeState; use std::{ cell::{Cell, RefCell}, io::{self}, + path::PathBuf, sync::Arc, time::{Duration, Instant}, }; @@ -54,32 +55,9 @@ mod wgt; pub async fn run(cfg: MasterConfig, config_found: bool) -> io::Result<()> { let mut terminal = ratatui::init(); - let (result, exit_message) = match SysInspectUX::new(cfg.clone()).await { - Ok(app) => (app.run_loop(&mut terminal), None), - Err(err) => { - if config_found { - let app = SysInspectUX { - cfg, - offline: true, - error_alert_visible: true, - error_alert_message: format!("Cannot connect to master.\n\n{err}\n\nStart the master, then reconnect."), - ..Default::default() - }; - (app.run_offline_loop(&mut terminal), None) - } else { - let app = SysInspectUX { cfg, setup_wizard: setup::MasterSetupWizard { visible: true, ..Default::default() }, ..Default::default() }; - let mut exit_message = None; - let r = app.run_setup_loop(&mut terminal, &mut exit_message); - (r, exit_message) - } - } - }; + let result = tokio_run(cfg, config_found, &mut terminal).await; ratatui::restore(); - if let Some(msg) = exit_message { - println!("\n{msg}\n"); - } - if !MEM_LOGGER.get_messages().is_empty() { println!("Memory log:"); println!("{:#?}", MEM_LOGGER.get_messages()); @@ -88,6 +66,29 @@ pub async fn run(cfg: MasterConfig, config_found: bool) -> io::Result<()> { result } +async fn tokio_run(cfg: MasterConfig, config_found: bool, term: &mut DefaultTerminal) -> io::Result<()> { + match SysInspectUX::new(cfg.clone()).await { + Ok(app) => app.run_connected(term), + Err(_) if config_found => { + let mut app = SysInspectUX { cfg, offline: true, ..Default::default() }; + if app.find_sysmaster_binary().is_some() { + app.master_confirm_visible = true; + app.master_confirm_choice = AlertResult::Default; + app.master_confirm_action = 1; + app.run_offline_loop(term) + } else { + app.setup_wizard = setup::MasterSetupWizard::from_config(&app.cfg); + app.setup_wizard.visible = true; + app.run_setup_loop(term, &mut None) + } + } + Err(_) => { + let app = SysInspectUX { cfg, setup_wizard: setup::MasterSetupWizard { visible: true, ..Default::default() }, ..Default::default() }; + app.run_setup_loop(term, &mut None) + } + } +} + #[derive(Debug, Clone, Copy, Default)] pub struct UISizes { pub table_cycles: usize, @@ -169,6 +170,11 @@ pub struct SysInspectUX { pub master_logs_polling: bool, pub master_logs_last_fetch: Instant, pub master_logs_viewport_rows: Cell, + pub master_menu_visible: bool, + pub master_menu_sel: usize, + pub master_confirm_visible: bool, + pub master_confirm_choice: AlertResult, + pub master_confirm_action: u8, // 0=none, 1=start, 2=restart, 3=stop // Online minions action menu pub minions_menu_visible: bool, @@ -280,6 +286,11 @@ impl Default for SysInspectUX { master_logs_polling: true, master_logs_last_fetch: Instant::now(), master_logs_viewport_rows: Cell::new(0), + master_menu_visible: false, + master_menu_sel: 0, + master_confirm_visible: false, + master_confirm_choice: AlertResult::default(), + master_confirm_action: 0, minions_menu_visible: false, minions_menu_sel: 0, @@ -333,6 +344,11 @@ impl SysInspectUX { self.run_normal_loop(term) } + fn run_connected(mut self, term: &mut DefaultTerminal) -> io::Result<()> { + self.cycles_buf = self.get_cycles().unwrap_or_default(); + self.run_normal_loop(term) + } + pub fn run_setup_loop(mut self, term: &mut DefaultTerminal, exit_msg: &mut Option) -> io::Result<()> { self.last_reconnect_attempt = Instant::now(); self.no_focus = true; @@ -341,7 +357,8 @@ impl SysInspectUX { term.draw(|frame| self.draw(frame))?; if self.setup_wizard.ok_pressed { if self.setup_wizard.sysmaster_path.value().is_empty() { - self.setup_wizard.error_message = Some("Sys Master binary must be selected.".to_string()); + self.error_alert_visible = true; + self.error_alert_message = "Sys Master binary must be selected.".to_string(); self.setup_wizard.ok_pressed = false; self.setup_wizard.focus = setup::SetupFocus::SysMasterPath; } else { @@ -357,12 +374,17 @@ impl SysInspectUX { std::path::PathBuf::from(self.setup_wizard.sysmaster_path.value()) }; std::process::Command::new(&master_bin) - .arg("--start") + .arg("--daemon") .arg("-c") .arg(config_path.to_string_lossy().as_ref()) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .spawn() + .map(|c| { + std::thread::spawn(move || { + c.wait_with_output().ok(); + }); + }) .ok(); // Wait for master to come up @@ -381,7 +403,8 @@ impl SysInspectUX { ); } Err(e) => { - self.setup_wizard.error_message = Some(e); + self.error_alert_visible = true; + self.error_alert_message = e; self.setup_wizard.ok_pressed = false; } } @@ -775,10 +798,6 @@ impl SysInspectUX { return false; } - if self.master_logs_visible { - return self.on_master_logs_popup(e); - } - if self.minion_logs_visible { return self.on_minion_logs_popup(e); } @@ -1172,13 +1191,19 @@ impl SysInspectUX { self.purge_alert_visible || self.error_alert_visible || self.exit_alert_visible + || self.info_alert_visible || self.help_popup_visible || self.minions_visible || self.minion_traits_visible || self.minion_logs_visible + || self.master_logs_visible || self.minions_menu_visible + || self.master_menu_visible + || self.master_confirm_visible || self.tag_visible || self.dsl_browser.visible + || self.setup_wizard.visible + || self.file_picker.visible } fn sync_main_focus_for_overlays(&mut self) { @@ -1651,6 +1676,27 @@ impl SysInspectUX { } } + fn load_master_logs_local(&mut self) -> Result<(), String> { + let std = std::fs::read_to_string(self.cfg.logfile_std()).map_err(|e| format!("Cannot read standard log: {e}"))?; + let err = std::fs::read_to_string(self.cfg.logfile_err()).map_err(|e| format!("Cannot read error log: {e}"))?; + self.master_logs_sections = vec![ + rawlogs::LogSection { + title: "Standard".into(), + path: self.cfg.logfile_std().display().to_string(), + lines: if std.is_empty() { vec!["(empty)".into()] } else { std.lines().map(|s| s.to_string()).collect() }, + scroll: Cell::new(usize::MAX), + }, + rawlogs::LogSection { + title: "Errors".into(), + path: self.cfg.logfile_err().display().to_string(), + lines: if err.is_empty() { vec!["(empty)".into()] } else { err.lines().map(|s| s.to_string()).collect() }, + scroll: Cell::new(usize::MAX), + }, + ]; + self.master_logs_last_fetch = Instant::now(); + Ok(()) + } + fn on_master_logs_popup(&mut self, e: event::KeyEvent) -> bool { if !self.master_logs_visible { return false; @@ -1773,6 +1819,171 @@ impl SysInspectUX { true } + fn find_sysmaster_binary(&self) -> Option { + // 1. Self-contained: {root}/bin/sysmaster + let root = self.cfg.root_dir(); + let candidate = root.join("bin/sysmaster"); + if candidate.exists() && candidate.is_file() { + return Some(candidate); + } + // 2. Same dir as current binary + if let Ok(exe) = std::env::current_exe() + && let Some(dir) = exe.parent() + { + let candidate = dir.join("sysmaster"); + if candidate.exists() && candidate.is_file() { + return Some(candidate); + } + } + None + } + + fn on_master_menu(&mut self, e: event::KeyEvent) -> bool { + if !self.master_menu_visible { + return false; + } + match e.code { + KeyCode::Esc => { + self.master_menu_visible = false; + self.status_at_cycles(); + } + KeyCode::Up => { + self.master_menu_sel = self.master_menu_sel.saturating_sub(1); + } + KeyCode::Down => { + self.master_menu_sel = (self.master_menu_sel + 1).min(macts::total_master_menu_items().saturating_sub(1)); + } + KeyCode::Enter => { + self.master_menu_visible = false; + match self.master_menu_sel { + 0 => { + if self.evtipc.is_some() { + self.open_master_logs(); + } else { + self.error_alert_visible = true; + self.error_alert_message = "Master is not running".to_string(); + } + } + 1 => match self.load_master_logs_local() { + Ok(()) => { + self.master_logs_visible = true; + self.master_logs_tab = 0; + self.master_logs_polling = false; + self.status_at_master_logs(); + } + Err(e) => { + self.error_alert_visible = true; + self.error_alert_message = e; + } + }, + 2 => { + self.error_alert_visible = true; + self.error_alert_message = "Not implemented yet".to_string(); + } + 3 => { + self.master_confirm_visible = true; + self.master_confirm_choice = AlertResult::Default; + self.master_confirm_action = 1; + } + 4 => { + self.master_confirm_visible = true; + self.master_confirm_choice = AlertResult::Default; + self.master_confirm_action = 3; + } + 5 => { + self.master_confirm_visible = true; + self.master_confirm_choice = AlertResult::Default; + self.master_confirm_action = 2; + } + _ => {} + } + self.status_at_cycles(); + } + _ => {} + } + true + } + + fn on_master_confirm(&mut self, e: event::KeyEvent) -> bool { + if !self.master_confirm_visible { + return false; + } + match e.code { + KeyCode::Tab => { + self.master_confirm_choice = + if self.master_confirm_choice == AlertResult::Default { AlertResult::Quit } else { AlertResult::Default }; + } + KeyCode::Esc => { + self.master_confirm_visible = false; + self.master_confirm_action = 0; + } + KeyCode::Enter => { + self.master_confirm_visible = false; + let action = self.master_confirm_action; + self.master_confirm_action = 0; + if self.master_confirm_choice == AlertResult::Quit { + match action { + 1 => self.do_master_start(), + 2 => self.do_master_restart(), + 3 => self.do_master_stop(), + _ => {} + } + } + } + _ => {} + } + true + } + + fn do_master_start(&mut self) { + if let Some(bin) = self.find_sysmaster_binary() { + let config_path = { + let root = self.cfg.root_dir(); + let etc_path = root.join("etc/sysinspect.conf"); + if etc_path.exists() { etc_path } else { root.join("sysinspect.conf") } + }; + let child = std::process::Command::new(&bin) + .arg("--daemon") + .arg("-c") + .arg(config_path.to_string_lossy().as_ref()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn(); + if let Ok(c) = child { + std::thread::spawn(move || { + c.wait_with_output().ok(); + }); + } + for _ in 0..10 { + std::thread::sleep(std::time::Duration::from_millis(500)); + if self.try_reconnect_silent().is_ok() { + return; + } + } + self.error_alert_visible = true; + self.error_alert_message = "Master started but not reachable yet.".to_string(); + } else { + self.error_alert_visible = true; + self.error_alert_message = "Cannot find sysmaster binary".to_string(); + } + } + + fn do_master_stop(&mut self) { + if let Err(err) = libsysinspect::util::sys::kill_process(self.cfg.pidfile(), None) { + self.error_alert_visible = true; + self.error_alert_message = format!("Failed to stop master: {err}"); + } else { + self.offline = true; + self.evtipc = None; + } + } + + fn do_master_restart(&mut self) { + self.do_master_stop(); + std::thread::sleep(std::time::Duration::from_secs(2)); + self.do_master_start(); + } + fn on_tag_popup(&mut self, e: event::KeyEvent) -> bool { if !self.tag_visible { return false; @@ -1952,6 +2163,11 @@ impl SysInspectUX { return; } + // Master operations menu is modal + if self.on_master_menu(e) { + return; + } + // Exit alert takes priority over setup wizard if self.on_exit_alert(e) { return; @@ -2028,6 +2244,10 @@ impl SysInspectUX { return; } + if self.on_master_confirm(e) { + return; + } + if self.on_error_alert(e) { return; } @@ -2036,6 +2256,10 @@ impl SysInspectUX { return; } + if self.on_master_logs_popup(e) { + return; + } + if self.on_minions_popup(e) { return; } @@ -2210,7 +2434,9 @@ impl SysInspectUX { } }, KeyCode::Char('m') if !e.modifiers.contains(KeyModifiers::CONTROL) => { - self.open_master_logs(); + self.master_menu_visible = true; + self.master_menu_sel = 0; + self.status_at_master_menu(); } KeyCode::Char('o') if !e.modifiers.contains(KeyModifiers::CONTROL) => match self.fetch_minions() { Ok(rows) if rows.is_empty() => { diff --git a/src/ui/setup.rs b/src/ui/setup.rs index a740a04c..dab414a3 100644 --- a/src/ui/setup.rs +++ b/src/ui/setup.rs @@ -131,6 +131,39 @@ impl Default for MasterSetupWizard { } impl MasterSetupWizard { + pub fn from_config(cfg: &libsysinspect::cfg::mmconf::MasterConfig) -> Self { + let root = cfg.root_dir(); + let is_system = root == *"/etc/sysinspect"; + let mut w = MasterSetupWizard { + installation_mode: if is_system { InstallationMode::SystemWide } else { InstallationMode::Custom }, + ..Default::default() + }; + + // Pre-fill from existing config + let bind = cfg.bind_addr(); + w.bind_addr.set_value(bind.split(':').next().unwrap_or("0.0.0.0").to_string()); + w.bind_port.set_value(bind.split(':').nth(1).unwrap_or("4200").to_string()); + + let fs = cfg.fileserver_bind_addr(); + w.fs_port.set_value(fs.split(':').nth(1).unwrap_or("4201").to_string()); + + w.api_enabled = cfg.api_enabled(); + + if !is_system { + w.custom_destination.set_value(root.to_string_lossy().to_string()); + } + + if let Ok(cwd) = std::env::current_dir() { + let candidate = cwd.join("sysmaster"); + if candidate.exists() && candidate.is_file() { + w.sysmaster_path.set_value(candidate.to_string_lossy().to_string()); + } + } + + w.focus = SetupFocus::SysMasterPath; + w + } + #[allow(clippy::too_many_arguments)] pub fn handle_key(&mut self, key: KeyEvent) -> bool { if !self.visible { @@ -474,8 +507,10 @@ impl MasterSetupWizard { std::fs::create_dir_all(&root).map_err(|e| format!("Cannot create root dir: {e}"))?; let telemetry_dir = root.join("telemetry"); let datastore_dir = root.join("datastore"); + let log_dir = root.join("log"); std::fs::create_dir_all(&telemetry_dir).map_err(|e| format!("Cannot create telemetry dir: {e}"))?; std::fs::create_dir_all(&datastore_dir).map_err(|e| format!("Cannot create datastore dir: {e}"))?; + std::fs::create_dir_all(&log_dir).map_err(|e| format!("Cannot create log dir: {e}"))?; // Determine config path and pre-create bin/ for self-contained layouts let is_system = matches!(self.installation_mode, InstallationMode::SystemWide); @@ -498,13 +533,15 @@ impl MasterSetupWizard { let fs_port: u32 = self.fs_port.value().parse().map_err(|_| "Invalid fileserver port".to_string())?; let partial = format!( - "root: \"{}\"\nbind.ip: \"{}\"\nbind.port: {}\nfileserver.bind.ip: \"{}\"\nfileserver.bind.port: {}\nfileserver.models: []\napi.enabled: {}\n", + "root: \"{}\"\nbind.ip: \"{}\"\nbind.port: {}\nfileserver.bind.ip: \"{}\"\nfileserver.bind.port: {}\nfileserver.models: []\napi.enabled: {}\nlog.stream: \"{}/log/sysmaster.standard.log\"\nlog.errors: \"{}/log/sysmaster.errors.log\"\n", root.display(), bind_addr, bind_port, bind_addr, fs_port, self.api_enabled, + root.display(), + root.display(), ); let master_cfg: MasterConfig = serde_yaml::from_str(&partial).map_err(|e| format!("Cannot construct config: {e}"))?; let yaml = SysInspectConfig::default().set_master_config(master_cfg).to_yaml(); diff --git a/src/ui/statusbar.rs b/src/ui/statusbar.rs index 1ea7d31f..71469e1b 100644 --- a/src/ui/statusbar.rs +++ b/src/ui/statusbar.rs @@ -166,6 +166,13 @@ impl SysInspectUX { self.status_text = Line::from(spans); } + pub(crate) fn status_at_master_menu(&mut self) { + let key = |s| Span::styled(s, Style::default().fg(palette::FG)); + let desc = |s| Span::styled(s, Style::default().fg(palette::FAINT)); + self.status_text = + Line::from(vec![key("\u{2191}\u{2193} "), desc("navigate, "), key("Enter "), desc("select, "), key("Esc "), desc("close")]); + } + pub(crate) fn status_at_query_composer(&mut self) { self.status_text = Line::from(vec![ Span::styled(" Tab ", Style::default().fg(palette::FG)), diff --git a/src/ui/wgt.rs b/src/ui/wgt.rs index c4351051..4b96348f 100644 --- a/src/ui/wgt.rs +++ b/src/ui/wgt.rs @@ -310,6 +310,8 @@ impl Widget for &SysInspectUX { self.dialog_master_logs(area, buf); self.dialog_trait_tag(area, buf); self.dialog_cluster_confirm(area, buf); + self.dialog_master_confirm(area, buf); + self.master_actions_menu(area, buf); self.dialog_dsl_browser(area, buf); self.dialog_error(area, buf); if self.info_alert_visible { From 421f22480dd4ad1fdcec74bd359a2b333a131ee1 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Thu, 11 Jun 2026 17:10:19 +0200 Subject: [PATCH 09/25] Add package picker --- libmodcore/src/modinit.rs | 5 + libmodpak/src/lib.rs | 3 + libmodpak/src/mpk.rs | 72 +++++- libsysinspect/src/console/mod.rs | 23 ++ libsysproto/src/query.rs | 3 + src/clidef.rs | 1 + src/clifmt.rs | 1 + src/ui/filepicker.rs | 16 +- src/ui/macts.rs | 7 +- src/ui/mod.rs | 158 +++++++++++- src/ui/repomanager.rs | 400 +++++++++++++++++++++++++++++++ src/ui/statusbar.rs | 19 ++ src/ui/wgt.rs | 7 +- sysmaster/src/console.rs | 41 +++- sysmaster/src/master.rs | 4 +- 15 files changed, 728 insertions(+), 32 deletions(-) create mode 100644 src/ui/repomanager.rs diff --git a/libmodcore/src/modinit.rs b/libmodcore/src/modinit.rs index 066f5222..f5c713bf 100644 --- a/libmodcore/src/modinit.rs +++ b/libmodcore/src/modinit.rs @@ -248,6 +248,11 @@ impl ModInterface { pub fn arguments(&self) -> &[ModArgument] { &self.arguments } + + /// Get optional manpage + pub fn manpage(&self) -> Option<&str> { + self.manpage.as_deref() + } } /// Include `mod_doc.yaml` from the project and embed it. diff --git a/libmodpak/src/lib.rs b/libmodpak/src/lib.rs index 2bdf8411..aeabd4f8 100644 --- a/libmodpak/src/lib.rs +++ b/libmodpak/src/lib.rs @@ -899,6 +899,9 @@ impl SysInspectModPak { &checksum, if meta.get_args().is_empty() { None } else { Some(meta.get_args().clone()) }, if meta.get_opts().is_empty() { None } else { Some(meta.get_opts().clone()) }, + meta.get_version().map(|s| s.to_string()), + meta.get_author().map(|s| s.to_string()), + meta.get_manpage().map(|s| s.to_string()), ) .map_err(|e| SysinspectError::MasterGeneralError(format!("Failed to add module to index: {e}")))?; log::debug!("Writing index to {}", self.root.join(REPO_MOD_INDEX).display().to_string().bright_yellow()); diff --git a/libmodpak/src/mpk.rs b/libmodpak/src/mpk.rs index fdb2720e..ee0b7d39 100644 --- a/libmodpak/src/mpk.rs +++ b/libmodpak/src/mpk.rs @@ -21,26 +21,30 @@ static RUNTIME_PREFIX: &str = "runtime"; #[derive(Debug, Serialize, Deserialize, Clone)] pub struct ModAttrs { - subpath: String, - descr: String, + pub subpath: String, + pub descr: String, #[serde(rename = "type")] - mod_type: String, + pub mod_type: String, #[serde(rename = "sha256")] - checksum: String, + pub checksum: String, - #[serde(skip_serializing_if = "Option::is_none")] - args: Option>, + pub args: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - opts: Option>, + pub opts: Option>, + + pub version: Option, + + pub author: Option, + + pub manpage: Option, } impl ModAttrs { /// Creates a new ModAttrs with the given subpath, description, and type. pub fn new(subpath: String, descr: String, mod_type: String, checksum: String) -> Self { - Self { subpath, descr, mod_type, checksum, args: None, opts: None } + Self { subpath, descr, mod_type, checksum, args: None, opts: None, version: None, author: None, manpage: None } } /// Returns the subpath of the module. @@ -345,9 +349,8 @@ impl ModPakProfile { #[allow(clippy::type_complexity)] #[derive(Debug, Serialize, Deserialize)] pub struct ModPakRepoIndex { - /// Platform -> Architecture -> Module name - /// e.g. "linux" -> "x86_64" -> "fs.file" -> key/value (name, descr, version etc) - platform: IndexMap>>, + // Platform -> Architecture -> Module name + pub platform: IndexMap>>, /// Simply files. They are all the same on all minions for all platforms and architectures. /// Usually they are meant to be just Python scripts. Possibly .so files could be also @@ -415,7 +418,7 @@ impl ModPakRepoIndex { #[allow(clippy::too_many_arguments)] pub fn index_module( &mut self, name: &str, subpath: &str, platform: &str, arch: &str, descr: &str, bin: bool, checksum: &str, args: Option>, - opts: Option>, + opts: Option>, version: Option, author: Option, manpage: Option, ) -> Result<(), SysinspectError> { let attrs = ModAttrs { subpath: subpath.to_string(), @@ -424,6 +427,9 @@ impl ModPakRepoIndex { checksum: checksum.to_string(), args, opts, + version, + author, + manpage, }; self.platform.entry(platform.to_string()).or_default().entry(arch.to_string()).or_default().insert(name.to_string(), attrs); @@ -668,6 +674,9 @@ pub struct ModPakMetadata { arch: String, arguments: Vec, options: Vec, + version: Option, + author: Option, + manpage: Option, } impl ModPakMetadata { @@ -811,6 +820,31 @@ impl ModPakMetadata { )); } + // Version: from spec (mandatory) or --version CLI (mandatory when no spec) + let spec_exists = spec.exists(); + if spec_exists { + if mi.version().is_empty() { + return Err(SysinspectError::InvalidModuleName("version is mandatory in the spec file".to_string())); + } + mpm.version = Some(mi.version().to_string()); + } else if let Some(v) = matches.get_one::("version") { + mpm.version = Some(v.clone()); + } else { + return Err(SysinspectError::InvalidModuleName( + format!("Module version is required. Either add a spec file or use the {} option.", "--version".bright_yellow()).to_string(), + )); + } + + // Author: spec only, optional + if spec_exists && !mi.author().is_empty() { + mpm.author = Some(mi.author().to_string()); + } + + // Manpage: spec only, optional + if spec_exists { + mpm.manpage = mi.manpage().map(|s| s.to_string()); + } + mpm.load_args(mi.arguments().to_vec()); mpm.load_opts(mi.options().to_vec()); @@ -829,6 +863,18 @@ impl ModPakMetadata { pub(crate) fn get_descr(&self) -> &str { &self.descr } + + pub(crate) fn get_version(&self) -> Option<&str> { + self.version.as_deref() + } + + pub(crate) fn get_author(&self) -> Option<&str> { + self.author.as_deref() + } + + pub(crate) fn get_manpage(&self) -> Option<&str> { + self.manpage.as_deref() + } } /// Module is a single unit of functionality that can be used in a ModPack. diff --git a/libsysinspect/src/console/mod.rs b/libsysinspect/src/console/mod.rs index a8f932e1..0192a84a 100644 --- a/libsysinspect/src/console/mod.rs +++ b/libsysinspect/src/console/mod.rs @@ -126,6 +126,24 @@ pub struct ConsoleMasterLogSnapshot { pub errors_path: String, } +/// One row in the master's module repository index. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ConsoleModuleRow { + pub name: String, + pub platform: String, + pub arch: String, + pub subpath: String, + pub descr: String, + #[serde(rename = "type")] + pub mod_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub author: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub manpage: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum ConsolePayload { @@ -188,6 +206,11 @@ pub enum ConsolePayload { /// Snapshot payload. snapshot: ConsoleMasterLogSnapshot, }, + /// Module repository index from the master. + MasterModuleIndex { + /// One row per indexed module. + rows: Vec, + }, /// Available models discovered by the master. Models { /// One row per discovered model. diff --git a/libsysproto/src/query.rs b/libsysproto/src/query.rs index 4a9252c6..7aa067be 100644 --- a/libsysproto/src/query.rs +++ b/libsysproto/src/query.rs @@ -64,6 +64,9 @@ pub mod commands { // Read recent raw log snapshot from the master (standard + error logs) pub const CLUSTER_MASTER_LOGS: &str = "cluster/master/logs"; + + // Get the module repository index from the master + pub const CLUSTER_MODULE_INDEX: &str = "cluster/module/index"; } /// diff --git a/src/clidef.rs b/src/clidef.rs index 1359463d..ce1ad799 100644 --- a/src/clidef.rs +++ b/src/clidef.rs @@ -29,6 +29,7 @@ pub fn cli(version: &'static str) -> Command { .arg(Arg::new("name").short('n').long("name").help("Specify the module name")) .arg(Arg::new("path").short('p').long("path").required_unless_present_any(["help", "list", "remove", "info", "platform"]).help("Specify the path to the module (or library)")) .arg(Arg::new("descr").short('d').long("descr").help("Provide a description of the module")) + .arg(Arg::new("version").short('v').long("version").help("Module version (required when no .spec file is present)")) .arg(Arg::new("arch").short('a').long("arch").help("Specify the module architecture (x86, x64, arm, arm64, noarch)").default_value("noarch")) .arg(Arg::new("help").short('h').long("help").action(ArgAction::SetTrue).help("Display help for this command")) ) diff --git a/src/clifmt.rs b/src/clifmt.rs index 0e969653..92b094cc 100644 --- a/src/clifmt.rs +++ b/src/clifmt.rs @@ -413,5 +413,6 @@ pub fn render_console_payload(payload: &ConsolePayload) -> String { } ConsolePayload::Models { .. } => String::new(), ConsolePayload::MasterLogs { snapshot: _ } => String::new(), + ConsolePayload::MasterModuleIndex { .. } => String::new(), } } diff --git a/src/ui/filepicker.rs b/src/ui/filepicker.rs index 833a482f..1d47a32b 100644 --- a/src/ui/filepicker.rs +++ b/src/ui/filepicker.rs @@ -25,6 +25,7 @@ use unicode_width::UnicodeWidthStr; pub enum PickerMode { DirectoryPicker, FilePicker, + Any, } #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -298,6 +299,7 @@ impl FilePicker { && (entry.is_parent || entry.is_dir) { self.current_path = entry.path.clone(); + self.filter_input = InputState::new(); self.dir_cursor = 0; self.file_cursor = 0; self.refresh_entries(); @@ -312,12 +314,13 @@ impl FilePicker { PickerFocus::Dirs => self.dir_cursor, PickerFocus::Files => self.dirs_end + self.file_cursor, }; - if let Some(entry) = self.entries.get(idx) - && !entry.is_parent - && !entry.is_dir - { - self.selected = Some(entry.path.clone()); - self.visible = false; + if let Some(entry) = self.entries.get(idx) { + let selectable = if self.mode == PickerMode::Any { !entry.is_parent } else { !entry.is_parent && !entry.is_dir }; + if selectable { + self.selected = Some(entry.path.clone()); + self.filter_input = InputState::new(); + self.visible = false; + } } } } @@ -376,6 +379,7 @@ impl FilePicker { let title_text = match self.mode { PickerMode::DirectoryPicker => " Directory Selector ", PickerMode::FilePicker => " File Selector ", + PickerMode::Any => " Module Selector ", }; let title_style = TitleStyle::cyberpunk(palette::PROCESSING_GLOW); title::overlay_gradient_title( diff --git a/src/ui/macts.rs b/src/ui/macts.rs index 567915d6..8623eedc 100644 --- a/src/ui/macts.rs +++ b/src/ui/macts.rs @@ -30,7 +30,10 @@ const MENU_SECTIONS: &[MenuSection] = &[ ]; const MASTER_MENU_SECTIONS: &[MenuSection] = &[ - MenuSection { title: "Operations", items: &[("View master logs online", ""), ("View local logs", ""), ("Register a minion", "")] }, + MenuSection { + title: "Operations", + items: &[("View master logs online", ""), ("View local logs", ""), ("Register a minion", ""), ("Repository manager", "")], + }, MenuSection { title: "System", items: &[("Start", ""), ("Stop", ""), ("Restart", "")] }, ]; @@ -218,7 +221,7 @@ impl SysInspectUX { ]; let local_logs_available = self.cfg.logfile_std().exists() || self.cfg.logfile_err().exists(); - let disabled = [!local_logs_available, false, false, false, false, false]; + let disabled = [!local_logs_available, false, false, false, false, false, false]; render_menu_popup(parent, buf, MASTER_MENU_SECTIONS, self.master_menu_sel, &segments, &title_style, max_item_w, &disabled); } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 9aeef2ed..ca6aa97c 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -16,7 +16,7 @@ use libsysproto::query::{ SCHEME_COMMAND, commands::{ CLUSTER_MASTER_LOGS, CLUSTER_MINION_HOPSTART, CLUSTER_MINION_INFO, CLUSTER_MINION_LOGS, CLUSTER_MINION_RECONNECT, CLUSTER_MINION_SHUTDOWN, - CLUSTER_MODELS, CLUSTER_ONLINE_MINIONS, CLUSTER_RECONNECT, CLUSTER_SHUTDOWN, CLUSTER_TRAITS_UPDATE, + CLUSTER_MODELS, CLUSTER_MODULE_INDEX, CLUSTER_ONLINE_MINIONS, CLUSTER_RECONNECT, CLUSTER_SHUTDOWN, CLUSTER_TRAITS_UPDATE, }, }; use ratatui::{ @@ -45,6 +45,7 @@ mod macts; mod online; mod palette; mod rawlogs; +mod repomanager; mod setup; mod statusbar; mod title; @@ -207,6 +208,9 @@ pub struct SysInspectUX { // File picker pub file_picker: filepicker::FilePicker, + // Repository manager + pub repo_manager: repomanager::RepoManager, + // Connection state pub offline: bool, pub last_reconnect_attempt: Instant, @@ -310,6 +314,7 @@ impl Default for SysInspectUX { cfg: MasterConfig::default(), setup_wizard: setup::MasterSetupWizard::default(), file_picker: filepicker::FilePicker::default(), + repo_manager: repomanager::RepoManager::default(), offline: false, last_reconnect_attempt: Instant::now(), @@ -450,6 +455,9 @@ impl SysInspectUX { if self.file_picker.visible { self.status_text = self.file_picker.status_line(); } + if self.repo_manager.visible { + self.status_at_repo_manager(); + } // Status bar for sysmaster path focus if self.setup_wizard.focus == setup::SetupFocus::SysMasterPath { self.status_text = Line::from(vec![ @@ -477,6 +485,9 @@ impl SysInspectUX { if self.file_picker.visible { self.status_text = self.file_picker.status_line(); } + if self.repo_manager.visible { + self.status_at_repo_manager(); + } term.draw(|frame| self.draw(frame))?; self.on_events()?; if self.offline && self.last_reconnect_attempt.elapsed() >= Duration::from_secs(5) { @@ -497,6 +508,9 @@ impl SysInspectUX { if self.file_picker.visible { self.status_text = self.file_picker.status_line(); } + if self.repo_manager.visible { + self.status_at_repo_manager(); + } term.draw(|frame| self.draw(frame))?; // Periodic silent reconnect attempt if self.last_reconnect_attempt.elapsed() >= Duration::from_secs(5) { @@ -627,6 +641,12 @@ impl SysInspectUX { } } } + // Process file picker result for repo manager + if self.repo_manager.visible + && let Some(path) = self.file_picker.selected.take() + { + self.process_module_add(&path); + } Ok(()) } @@ -1697,6 +1717,124 @@ impl SysInspectUX { Ok(()) } + fn load_module_index(&mut self) -> Result<(), String> { + let resp = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current() + .block_on(async { call_master_console(&self.cfg, &format!("{SCHEME_COMMAND}{CLUSTER_MODULE_INDEX}"), "*", None, None, None).await }) + }) + .map_err(|e| format!("Failed to get module index: {e}"))?; + match resp.payload { + ConsolePayload::MasterModuleIndex { rows } => { + self.repo_manager.rows = rows; + self.repo_manager.cursor = 0; + Ok(()) + } + _ => Err("Unexpected console payload for module index".to_string()), + } + } + + fn on_repo_manager(&mut self, e: event::KeyEvent) -> bool { + if !self.repo_manager.visible { + return false; + } + if self.repo_manager.staging { + return self.repo_manager.handle_staging_key(e); + } + let page = 10usize; + match e.code { + KeyCode::Esc => { + self.repo_manager.visible = false; + } + KeyCode::Up => { + self.repo_manager.cursor = self.repo_manager.cursor.saturating_sub(1); + } + KeyCode::Down => { + self.repo_manager.cursor = (self.repo_manager.cursor + 1).min(self.repo_manager.rows.len().saturating_sub(1)); + } + KeyCode::PageUp => { + self.repo_manager.cursor = self.repo_manager.cursor.saturating_sub(page); + } + KeyCode::PageDown => { + self.repo_manager.cursor = (self.repo_manager.cursor + page).min(self.repo_manager.rows.len().saturating_sub(1)); + } + KeyCode::Enter => {} // placeholder + KeyCode::Delete => { + self.error_alert_visible = true; + self.error_alert_message = "Not implemented yet".to_string(); + } + KeyCode::Insert | KeyCode::Char('i') if !e.modifiers.contains(KeyModifiers::CONTROL) => { + self.file_picker.open(&std::env::current_dir().unwrap_or_default(), filepicker::PickerMode::Any); + } + KeyCode::Char('l') if !e.modifiers.contains(KeyModifiers::CONTROL) => { + self.error_alert_visible = true; + self.error_alert_message = "Not implemented yet".to_string(); + } + _ => {} + } + true + } + + fn process_module_add(&mut self, path: &std::path::Path) { + if path.is_dir() { + let staged = Self::scan_dir_for_modules(path); + if staged.is_empty() { + self.error_alert_visible = true; + self.error_alert_message = "No .spec files found in the selected directory".to_string(); + } else { + self.repo_manager.enter_staging(staged); + } + } else { + let spec = path.with_extension("spec"); + if spec.exists() { + let module_name = path.file_stem().unwrap_or_default().to_string_lossy().to_string(); + let (version, descr) = Self::read_spec_version_descr(&spec); + self.repo_manager.enter_staging(vec![repomanager::StagedModule { + name: module_name, + version, + descr, + path: path.to_path_buf(), + checked: true, + }]); + } else { + self.error_alert_visible = true; + self.error_alert_message = "Module has no specfile. Add this module manually via CLI.".to_string(); + } + } + } + + fn scan_dir_for_modules(root: &std::path::Path) -> Vec { + let mut staged = Vec::new(); + let Ok(entries) = std::fs::read_dir(root) else { return staged }; + for entry in entries.flatten() { + let sub = entry.path(); + if !sub.is_dir() { + continue; + } + let module_name = sub.file_name().unwrap_or_default().to_string_lossy().to_string(); + let spec = sub.join(format!("{module_name}.spec")); + if !spec.exists() { + continue; + } + let (version, descr) = Self::read_spec_version_descr(&spec); + let bin = sub.join(&module_name); + staged.push(repomanager::StagedModule { name: module_name, version, descr, path: if bin.exists() { bin } else { spec }, checked: true }); + } + staged + } + + fn read_spec_version_descr(spec: &std::path::Path) -> (Option, String) { + match std::fs::read_to_string(spec) { + Ok(yaml) => match serde_yaml::from_str::(&yaml) { + Ok(v) => ( + v.get("version").and_then(|v| v.as_str()).map(|s| s.to_string()), + v.get("description").and_then(|v| v.as_str()).map(|s| s.to_string()).unwrap_or_default(), + ), + Err(_) => (None, String::new()), + }, + Err(_) => (None, String::new()), + } + } + fn on_master_logs_popup(&mut self, e: event::KeyEvent) -> bool { if !self.master_logs_visible { return false; @@ -1881,16 +2019,25 @@ impl SysInspectUX { self.error_alert_message = "Not implemented yet".to_string(); } 3 => { + if let Err(err) = self.load_module_index() { + self.error_alert_visible = true; + self.error_alert_message = err; + } else { + self.repo_manager.visible = true; + self.status_at_repo_manager(); + } + } + 4 => { self.master_confirm_visible = true; self.master_confirm_choice = AlertResult::Default; self.master_confirm_action = 1; } - 4 => { + 5 => { self.master_confirm_visible = true; self.master_confirm_choice = AlertResult::Default; self.master_confirm_action = 3; } - 5 => { + 6 => { self.master_confirm_visible = true; self.master_confirm_choice = AlertResult::Default; self.master_confirm_action = 2; @@ -2192,6 +2339,11 @@ impl SysInspectUX { return; } + // Repo manager is modal + if self.on_repo_manager(e) { + return; + } + if self.dsl_browser.visible { self.dsl_browser.handle_key(e.code); if !self.dsl_browser.visible { diff --git a/src/ui/repomanager.rs b/src/ui/repomanager.rs new file mode 100644 index 00000000..dfb2a9a4 --- /dev/null +++ b/src/ui/repomanager.rs @@ -0,0 +1,400 @@ +use super::{ + palette, + title::{self, TitleSegment, TitleStyle}, +}; +use libsysinspect::console::ConsoleModuleRow; +use ratatui::{ + layout::Position, + prelude::{Buffer, Rect}, + style::{Color, Modifier, Style}, + widgets::{Block, BorderType, Borders, Clear, Widget}, +}; +use ratatui_glamour::color::blend_2d; +use std::cell::Cell; + +#[derive(Debug, Clone)] +pub struct StagedModule { + pub name: String, + pub version: Option, + pub descr: String, + pub path: std::path::PathBuf, + pub checked: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StagingFocus { + List, + AddSelected, + Cancel, +} + +#[derive(Debug)] +pub struct RepoManager { + pub visible: bool, + pub rows: Vec, + pub cursor: usize, + pub scroll: Cell, + + // Staging + pub staging: bool, + pub staged: Vec, + pub staging_cursor: usize, + pub staging_scroll: Cell, + pub staging_focus: StagingFocus, +} + +impl Default for RepoManager { + fn default() -> Self { + Self { + visible: false, + rows: Vec::new(), + cursor: 0, + scroll: Cell::new(0), + staging: false, + staged: Vec::new(), + staging_cursor: 0, + staging_scroll: Cell::new(0), + staging_focus: StagingFocus::List, + } + } +} + +impl RepoManager { + pub fn enter_staging(&mut self, modules: Vec) { + self.staged = modules; + self.staging_cursor = 0; + self.staging_scroll = Cell::new(0); + self.staging_focus = StagingFocus::List; + self.staging = true; + } + + pub fn exit_staging(&mut self) { + self.staging = false; + self.staged.clear(); + } + + pub fn handle_staging_key(&mut self, key: crossterm::event::KeyEvent) -> bool { + if !self.staging { + return false; + } + match key.code { + crossterm::event::KeyCode::Esc => { + self.exit_staging(); + } + crossterm::event::KeyCode::Tab => { + use StagingFocus::*; + self.staging_focus = match self.staging_focus { + List => AddSelected, + AddSelected => Cancel, + Cancel => List, + }; + } + crossterm::event::KeyCode::BackTab => { + use StagingFocus::*; + self.staging_focus = match self.staging_focus { + List => Cancel, + AddSelected => List, + Cancel => AddSelected, + }; + } + crossterm::event::KeyCode::Up if self.staging_focus == StagingFocus::List => { + self.staging_cursor = self.staging_cursor.saturating_sub(1); + } + crossterm::event::KeyCode::Down if self.staging_focus == StagingFocus::List => { + self.staging_cursor = (self.staging_cursor + 1).min(self.staged.len().saturating_sub(1)); + } + crossterm::event::KeyCode::Char(' ') if self.staging_focus == StagingFocus::List => { + if let Some(m) = self.staged.get_mut(self.staging_cursor) { + m.checked = !m.checked; + } + } + crossterm::event::KeyCode::Enter => match self.staging_focus { + StagingFocus::AddSelected => { + // TODO: bulk register checked modules + self.exit_staging(); + } + StagingFocus::Cancel => { + self.exit_staging(); + } + _ => {} + }, + _ => {} + } + true + } + + pub fn render(&self, parent: Rect, buf: &mut Buffer) { + if !self.visible { + return; + } + if self.staging { + self.render_staging(parent, buf); + } else { + self.render_main(parent, buf); + } + } + + fn render_main(&self, parent: Rect, buf: &mut Buffer) { + let dlg_w = (parent.width * 3 / 4).clamp(70, 110); + let dlg_h = (parent.height * 3 / 4).clamp(10, 24); + let x = parent.x + (parent.width.saturating_sub(dlg_w)) / 2; + let y = parent.y + (parent.height.saturating_sub(dlg_h)) / 2; + let canvas = Rect { x, y, width: dlg_w, height: dlg_h }; + + Clear.render(canvas, buf); + + let grad = blend_2d(canvas.width as usize, canvas.height as usize, 10.0, &[palette::GRAY_0, palette::BG_2] as &[Color]); + for row in 0..canvas.height { + for col in 0..canvas.width { + let idx = row as usize * canvas.width as usize + col as usize; + if let Some(cell) = buf.cell_mut(Position::new(canvas.x + col, canvas.y + row)) { + cell.set_bg(grad[idx]); + } + } + } + + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(palette::PROCESSING_GLOW)) + .style(Style::default()); + let inner = block.inner(canvas); + block.render(canvas, buf); + + let title_style = TitleStyle::cyberpunk(palette::PROCESSING_GLOW); + title::overlay_gradient_title( + buf, + canvas, + &title_style, + &[TitleSegment { text: " Module and Library Manager ".into(), bg: palette::PROCESSING_BASE, fg: palette::FG }], + ); + + if inner.height >= 3 && !self.rows.is_empty() { + let name_w: u16 = 28; + let ver_w: u16 = 6; + let desc_w = inner.width.saturating_sub(name_w + ver_w + 2); + + let view_h = inner.height as usize; + let total = self.rows.len(); + let max_scroll = total.saturating_sub(view_h); + let mut s = self.scroll.get(); + let cursor = self.cursor.min(total.saturating_sub(1)); + if cursor < s { + s = cursor; + } + if cursor >= s + view_h { + s = cursor.saturating_sub(view_h.saturating_sub(1)); + } + s = s.min(max_scroll); + self.scroll.set(s); + + let muted = Style::default().fg(palette::MUTED); + let hl = Style::default().fg(palette::BLACK).bg(palette::HIGHLIGHT); + let dim_hl = Style::default().fg(palette::BG_0).bg(palette::HIGHLIGHT); + + for i in 0..view_h.min(total.saturating_sub(s)) { + let idx = s + i; + let ry = inner.y + i as u16; + let row = &self.rows[idx]; + let selected = idx == cursor; + let row_style = if selected { hl } else { Style::default().fg(palette::FG) }; + let dim = if selected { dim_hl } else { muted }; + + let name = truncate_str(&row.name, name_w as usize); + buf.set_string(inner.x + 1, ry, format!(" {name}"), row_style); + let ver = row.version.as_deref().unwrap_or("—"); + buf.set_string(inner.x + 1 + name_w + 1, ry, truncate_str(ver, ver_w as usize), dim); + let desc_x = inner.x + 1 + name_w + 1 + ver_w + 1; + let max_desc = desc_w.saturating_sub(1) as usize; + buf.set_string(desc_x, ry, truncate_str(&row.descr, max_desc), dim); + } + + if total > view_h { + let bar_h = ((view_h as f64 / total as f64) * view_h as f64).max(1.0) as usize; + let bar_y = ((s as f64 / total as f64) * (view_h - bar_h) as f64) as usize; + for i in 0..view_h { + let sx = inner.right().saturating_sub(1); + let sy = inner.y + i as u16; + if i >= bar_y && i < bar_y + bar_h { + buf.set_string(sx, sy, "█", Style::default().fg(palette::PROCESSING_HEAT)); + } else { + buf.set_string(sx, sy, "│", Style::default().fg(palette::MUTED)); + } + } + } + } else if inner.height >= 3 { + let msg = "(no modules found)"; + let x = inner.x + (inner.width.saturating_sub(msg.len() as u16)) / 2; + let y = inner.y + inner.height / 2; + buf.set_string(x, y, msg, Style::default().fg(palette::MUTED)); + } + + Self::draw_shadow(buf, canvas, dlg_w, dlg_h); + } + + fn render_staging(&self, parent: Rect, buf: &mut Buffer) { + let dlg_w = (parent.width * 3 / 4).clamp(70, 110); + let module_rows = self.staged.len().min(20) as u16; + let btn_height: u16 = 2; + let dlg_h = (module_rows + btn_height + 2).clamp(8, parent.height * 3 / 4); + let x = parent.x + (parent.width.saturating_sub(dlg_w)) / 2; + let y = parent.y + (parent.height.saturating_sub(dlg_h)) / 2; + let canvas = Rect { x, y, width: dlg_w, height: dlg_h }; + + Clear.render(canvas, buf); + + let grad = blend_2d(canvas.width as usize, canvas.height as usize, 10.0, &[palette::GRAY_0, palette::BG_2] as &[Color]); + for row in 0..canvas.height { + for col in 0..canvas.width { + let idx = row as usize * canvas.width as usize + col as usize; + if let Some(cell) = buf.cell_mut(Position::new(canvas.x + col, canvas.y + row)) { + cell.set_bg(grad[idx]); + } + } + } + + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(palette::PROCESSING_GLOW)) + .style(Style::default()); + let inner = block.inner(canvas); + block.render(canvas, buf); + + let title_style = TitleStyle::cyberpunk(palette::PROCESSING_GLOW); + title::overlay_gradient_title( + buf, + canvas, + &title_style, + &[TitleSegment { text: " Module and Library Manager ".into(), bg: palette::PROCESSING_BASE, fg: palette::FG }], + ); + + if inner.height < 6 || self.staged.is_empty() { + return; + } + + let list_height = inner.height.saturating_sub(btn_height); + + let name_w: u16 = 28; + let ver_w: u16 = 6; + + let view_h = list_height as usize; + let total = self.staged.len(); + let max_scroll = total.saturating_sub(view_h); + let mut s = self.staging_scroll.get(); + let cursor = self.staging_cursor.min(total.saturating_sub(1)); + if cursor < s { + s = cursor; + } + if cursor >= s + view_h { + s = cursor.saturating_sub(view_h.saturating_sub(1)); + } + s = s.min(max_scroll); + self.staging_scroll.set(s); + + let hl = Style::default().fg(palette::BLACK).bg(palette::HIGHLIGHT); + + for i in 0..view_h.min(total.saturating_sub(s)) { + let idx = s + i; + let ry = inner.y + i as u16; + let m = &self.staged[idx]; + let sel = idx == cursor && self.staging_focus == StagingFocus::List; + let row_style = if sel { hl } else { Style::default().fg(palette::FG) }; + + // Fill entire row with highlight background when selected + if sel { + for cx in 0..inner.width { + if let Some(cell) = buf.cell_mut(Position::new(inner.x + cx, ry)) { + cell.set_bg(palette::HIGHLIGHT); + } + } + } + + let (check_ch, check_style) = + if m.checked { ("▣", Style::default().fg(palette::SUCCESS)) } else { ("□", Style::default().fg(palette::GRAY_1)) }; + buf.set_string(inner.x + 1, ry, check_ch, if sel { row_style } else { check_style }); + + let name = truncate_str(&m.name, name_w as usize); + buf.set_string(inner.x + 5, ry, &name, row_style); + + let ver = m.version.as_deref().unwrap_or("—"); + let ver_style = if sel { row_style } else { Style::default().fg(palette::HIGHLIGHT) }; + buf.set_string(inner.x + 5 + name_w + 1, ry, truncate_str(ver, ver_w as usize), ver_style); + + let desc_x = inner.x + 5 + name_w + 1 + ver_w + 1; + let max_desc = inner.width.saturating_sub(5 + name_w + ver_w + 4) as usize; + let desc_style = if sel { row_style } else { Style::default().fg(palette::GRAY_1) }; + buf.set_string(desc_x, ry, truncate_str(&m.descr, max_desc), desc_style); + } + + if total > view_h { + let bar_h = ((view_h as f64 / total as f64) * view_h as f64).max(1.0) as usize; + let bar_y = ((s as f64 / total as f64) * (view_h - bar_h) as f64) as usize; + for i in 0..view_h { + let sx = inner.right().saturating_sub(1); + let sy = inner.y + i as u16; + if i >= bar_y && i < bar_y + bar_h { + buf.set_string(sx, sy, "█", Style::default().fg(palette::PROCESSING_HEAT)); + } else { + buf.set_string(sx, sy, "│", Style::default().fg(palette::MUTED)); + } + } + } + + // Buttons + let btn_y = inner.y + list_height + 1; + let add_label = "[ Add Selected ]"; + let cancel_label = "[ Cancel ]"; + let add_w = add_label.len() as u16; + let cancel_w = cancel_label.len() as u16; + let total_btn_w = add_w + cancel_w + 6; + let btn_x = inner.x + (inner.width.saturating_sub(total_btn_w)) / 2; + + let sel_btn = Style::default().fg(palette::WHITE).bg(palette::PROCESSING_HEAT).add_modifier(Modifier::BOLD); + let unsel_btn = Style::default().fg(palette::FG).bg(palette::BG_2).add_modifier(Modifier::BOLD); + + let add_style = if self.staging_focus == StagingFocus::AddSelected { sel_btn } else { unsel_btn }; + let cancel_style = if self.staging_focus == StagingFocus::Cancel { sel_btn } else { unsel_btn }; + + buf.set_string(btn_x, btn_y, add_label, add_style); + buf.set_string(btn_x + add_w + 4, btn_y, cancel_label, cancel_style); + + Self::draw_shadow(buf, canvas, dlg_w, dlg_h); + } + + fn draw_shadow(buf: &mut Buffer, canvas: Rect, dlg_w: u16, dlg_h: u16) { + let buf_area = buf.area(); + let x = canvas.x; + let y = canvas.y; + let max_x = buf_area.right().saturating_sub(1); + let max_y = buf_area.bottom().saturating_sub(1); + for idx in 0..dlg_w { + let sx = x.saturating_add(2).saturating_add(idx); + let sy = y.saturating_add(dlg_h); + if sx > max_x || sy > max_y { + continue; + } + if let Some(cell) = buf.cell_mut(Position::new(sx, sy)) { + cell.set_bg(palette::SHADOW_BG); + cell.set_fg(palette::SHADOW_FG); + } + } + for offset in 0..2u16 { + for idx in 0..dlg_h { + let sx = x.saturating_add(dlg_w).saturating_add(offset); + let sy = y.saturating_add(idx).saturating_add(1); + if sx > max_x || sy > max_y { + continue; + } + if let Some(cell) = buf.cell_mut(Position::new(sx, sy)) { + cell.set_bg(palette::SHADOW_BG); + cell.set_fg(palette::SHADOW_FG); + } + } + } + } +} + +fn truncate_str(s: &str, max_w: usize) -> String { + if s.len() <= max_w { s.to_string() } else { format!("{}…", &s[..max_w.saturating_sub(1)]) } +} diff --git a/src/ui/statusbar.rs b/src/ui/statusbar.rs index 71469e1b..570a2475 100644 --- a/src/ui/statusbar.rs +++ b/src/ui/statusbar.rs @@ -173,6 +173,25 @@ impl SysInspectUX { Line::from(vec![key("\u{2191}\u{2193} "), desc("navigate, "), key("Enter "), desc("select, "), key("Esc "), desc("close")]); } + pub(crate) fn status_at_repo_manager(&mut self) { + let key = |s| Span::styled(s, Style::default().fg(palette::FG)); + let desc = |s| Span::styled(s, Style::default().fg(palette::FAINT)); + self.status_text = Line::from(vec![ + key("\u{2191}\u{2193} "), + desc("navigate "), + key("Enter "), + desc("info "), + key("Del "), + desc("remove "), + key("Ins/i "), + desc("add "), + key("L "), + desc("libraries "), + key("Esc "), + desc("close"), + ]); + } + pub(crate) fn status_at_query_composer(&mut self) { self.status_text = Line::from(vec![ Span::styled(" Tab ", Style::default().fg(palette::FG)), diff --git a/src/ui/wgt.rs b/src/ui/wgt.rs index 4b96348f..59d4fad4 100644 --- a/src/ui/wgt.rs +++ b/src/ui/wgt.rs @@ -294,9 +294,6 @@ impl Widget for &SysInspectUX { self._render_right_pane(events_a, buf); // Catch dialogs - if self.file_picker.visible { - self.file_picker.render(area, buf); - } if !self.error_alert_visible && !self.file_picker.visible { self.setup_wizard.render(area, buf); } @@ -312,6 +309,10 @@ impl Widget for &SysInspectUX { self.dialog_cluster_confirm(area, buf); self.dialog_master_confirm(area, buf); self.master_actions_menu(area, buf); + self.repo_manager.render(area, buf); + if self.file_picker.visible { + self.file_picker.render(area, buf); + } self.dialog_dsl_browser(area, buf); self.dialog_error(area, buf); if self.info_alert_visible { diff --git a/sysmaster/src/console.rs b/sysmaster/src/console.rs index 642741f0..353fe877 100644 --- a/sysmaster/src/console.rs +++ b/sysmaster/src/console.rs @@ -8,13 +8,13 @@ use super::*; use crate::hopstart::{HopStartTarget, HopStarter}; -use libmodpak::{SysInspectModPak, compare_versions}; +use libmodpak::{SysInspectModPak, compare_versions, mpk::ModPakRepoIndex}; use libsysinspect::{ cfg::mmconf::MinionConfig, console::{ ConsoleEnvelope, ConsoleMasterLogSnapshot, ConsoleMinionInfoRow, ConsoleMinionLogRequest, ConsoleMinionLogSnapshot, ConsoleModelRow, - ConsoleOnlineMinionRow, ConsolePayload, ConsoleQuery, ConsoleResponse, ConsoleSealed, ConsoleTransportStatusRow, MinionCommandReply, - authorised_console_client, load_master_private_key, + ConsoleModuleRow, ConsoleOnlineMinionRow, ConsolePayload, ConsoleQuery, ConsoleResponse, ConsoleSealed, ConsoleTransportStatusRow, + MinionCommandReply, authorised_console_client, load_master_private_key, }, context::get_context, mdescr::catalog::ModelCatalog, @@ -507,6 +507,34 @@ impl SysMaster { )) } + async fn module_index_data(&mut self) -> Result, SysinspectError> { + let idx_path = self.cfg.fileserver_root().join("repo").join("mod.index"); + if !idx_path.exists() { + return Ok(Vec::new()); + } + let yaml = std::fs::read_to_string(&idx_path).map_err(|e| SysinspectError::ConfigError(format!("Cannot read module index: {e}")))?; + let idx = ModPakRepoIndex::from_yaml(&yaml)?; + let mut rows = Vec::new(); + for (platform, arch_map) in idx.platform.iter() { + for (arch, mod_map) in arch_map.iter() { + for (name, attrs) in mod_map.iter() { + rows.push(ConsoleModuleRow { + name: name.clone(), + platform: platform.clone(), + arch: arch.clone(), + subpath: attrs.subpath.clone(), + descr: attrs.descr.clone(), + mod_type: attrs.mod_type.clone(), + version: attrs.version.clone(), + author: attrs.author.clone(), + manpage: attrs.manpage.clone(), + }); + } + } + } + Ok(rows) + } + async fn upsert_cmdb_console_response(&mut self, mid: &str, context: &str) -> Result { if mid.trim().is_empty() { return Ok(ConsoleResponse::err("CMDB update requires a minion id")); @@ -712,6 +740,13 @@ impl SysMaster { }; } + if query.model.eq(&format!("{SCHEME_COMMAND}{CLUSTER_MODULE_INDEX}")) { + return match master.lock().await.module_index_data().await { + Ok(rows) => ConsoleResponse::ok(ConsolePayload::MasterModuleIndex { rows }), + Err(err) => ConsoleResponse::err(format!("Unable to get module index: {err}")), + }; + } + if query.model.eq(&format!("{SCHEME_COMMAND}{CLUSTER_TRANSPORT_STATUS}")) { return match TransportStatusConsoleRequest::from_context(&query.context) { Ok(request) => match master.lock().await.transport_status_data(&request, &query.query, &query.traits, &query.mid).await { diff --git a/sysmaster/src/master.rs b/sysmaster/src/master.rs index 6217c5aa..c166a819 100644 --- a/sysmaster/src/master.rs +++ b/sysmaster/src/master.rs @@ -40,8 +40,8 @@ use libsysproto::{ SCHEME_COMMAND, commands::{ CLUSTER_CMDB_UPSERT, CLUSTER_HOPSTART, CLUSTER_MASTER_LOGS, CLUSTER_MINION_HOPSTART, CLUSTER_MINION_INFO, CLUSTER_MINION_LOGS, - CLUSTER_MINION_RECONNECT, CLUSTER_MINION_SHUTDOWN, CLUSTER_MODELS, CLUSTER_ONLINE_MINIONS, CLUSTER_PROFILE, CLUSTER_REMOVE_MINION, - CLUSTER_ROTATE, CLUSTER_TRAITS_UPDATE, CLUSTER_TRANSPORT_STATUS, + CLUSTER_MINION_RECONNECT, CLUSTER_MINION_SHUTDOWN, CLUSTER_MODELS, CLUSTER_MODULE_INDEX, CLUSTER_ONLINE_MINIONS, CLUSTER_PROFILE, + CLUSTER_REMOVE_MINION, CLUSTER_ROTATE, CLUSTER_TRAITS_UPDATE, CLUSTER_TRANSPORT_STATUS, }, }, replay::{ReplayIdentity, replay_identity_for_master_command_cycle, replay_identity_from_minion_message}, From 69665dd35ec3975168c8bd7acab81e640998f589 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Thu, 11 Jun 2026 22:00:23 +0200 Subject: [PATCH 10/25] Lintfixes --- Cargo.lock | 1 + Cargo.toml | 1 + Makefile | 44 +++++++++- Makefile.in | 14 +++- libmodpak/src/mpk.rs | 24 ++++++ src/ui/mod.rs | 182 ++++++++++++++++++++++++++++++++++++++---- src/ui/repomanager.rs | 129 ++++++++++++++++++++++++++---- 7 files changed, 355 insertions(+), 40 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d0be52c4..cbc51a4e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8433,6 +8433,7 @@ dependencies = [ "jsonpath_lib", "libcommon", "libeventreg", + "libmodcore", "libmodpak", "libsetup", "libsysinspect", diff --git a/Cargo.toml b/Cargo.toml index 4fc32cab..7b1177d0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ colored = "3.1.1" libsysinspect = { path = "./libsysinspect" } libeventreg = { path = "./libeventreg" } libmodpak = { path = "./libmodpak" } +libmodcore = { path = "./libmodcore" } libcommon = { path = "./libcommon" } libsysproto = { path = "./libsysproto" } libsetup = { path = "./libsetup" } diff --git a/Makefile b/Makefile index 5084a94d..8f0b4e9c 100644 --- a/Makefile +++ b/Makefile @@ -39,10 +39,22 @@ help: @printf ' $(C_MX)%-20s$(C_OFF) %s\n' "modules-refresh" "Rebuild Linux musl module repo and refresh current minion slot." ifeq ($(UNAME_S),Linux) @printf '\n$(C_GRN)%s$(C_OFF)\n' "Cross Build" - @printf ' $(C_BLD)%-20s$(C_OFF) %s\n' "musl-x86_64" "Build static x86_64 Linux release artifacts." - @printf ' $(C_BLD)%-20s$(C_OFF) %s\n' "musl-x86_64-dev" "Build static x86_64 Linux debug artifacts." - @printf ' $(C_BLD)%-20s$(C_OFF) %s\n' "musl-aarch64" "Build static AArch64 Linux release artifacts." - @printf ' $(C_BLD)%-20s$(C_OFF) %s\n' "musl-aarch64-dev" "Build static AArch64 Linux debug artifacts." + @printf ' $(C_BLD)%-30s$(C_OFF) %s\n' "musl-x86_64" "Build static x86_64 Linux release artifacts." + @printf ' $(C_BLD)%-30s$(C_OFF) %s\n' "musl-x86_64-dev" "Build static x86_64 Linux debug artifacts." + @printf ' $(C_BLD)%-30s$(C_OFF) %s\n' "musl-aarch64" "Build static AArch64 Linux release artifacts." + @printf ' $(C_BLD)%-30s$(C_OFF) %s\n' "musl-aarch64-dev" "Build static AArch64 Linux debug artifacts." + @printf ' $(C_BLD)%-30s$(C_OFF) %s\n' "musl-x86_64-modules-dist" "Build static x86_64 Linux release modules distribution." + @printf ' $(C_BLD)%-30s$(C_OFF) %s\n' "musl-x86_64-modules-dist-dev" "Build static x86_64 Linux debug modules distribution." + @printf ' $(C_BLD)%-30s$(C_OFF) %s\n' "musl-aarch64-modules-dist" "Build static AArch64 Linux release modules distribution." + @printf ' $(C_BLD)%-30s$(C_OFF) %s\n' "musl-aarch64-modules-dist-dev" "Build static AArch64 Linux debug modules distribution." + @printf ' $(C_BLD)%-30s$(C_OFF) %s\n' "musl-x86_64" "Build static x86_64 Linux release artifacts." + @printf ' $(C_BLD)%-30s$(C_OFF) %s\n' "musl-x86_64-dev" "Build static x86_64 Linux debug artifacts." + @printf ' $(C_BLD)%-30s$(C_OFF) %s\n' "musl-aarch64" "Build static AArch64 Linux release artifacts." + @printf ' $(C_BLD)%-30s$(C_OFF) %s\n' "musl-aarch64-dev" "Build static AArch64 Linux debug artifacts." + @printf ' $(C_BLD)%-30s$(C_OFF) %s\n' "musl-x86_64-modules-dist" "Build static x86_64 Linux release modules distribution." + @printf ' $(C_BLD)%-30s$(C_OFF) %s\n' "musl-x86_64-modules-dist-dev" "Build static x86_64 Linux debug modules distribution." + @printf ' $(C_BLD)%-30s$(C_OFF) %s\n' "musl-aarch64-modules-dist" "Build static AArch64 Linux release modules distribution." + @printf ' $(C_BLD)%-30s$(C_OFF) %s\n' "musl-aarch64-modules-dist-dev" "Build static AArch64 Linux debug modules distribution." endif @printf '\n$(C_GRN)%s$(C_OFF)\n' "Testing" @printf ' $(C_MX)%-20s$(C_OFF) %s\n' "test" "Run the full nextest suite for this platform." @@ -152,6 +164,30 @@ musl-x86_64: $(call stage_profile_minion,release,x86_64-unknown-linux-musl) endif +musl-x86_64-modules-dist-dev: + $(call check_present,x86_64-linux-musl-gcc) + cargo build -v --workspace $(MUSL_WORKSPACE_EXCLUDES) --target x86_64-unknown-linux-musl + $(call stage_profile_modules,debug,x86_64-unknown-linux-musl) + $(call stage_modules_dist_from,debug,x86_64-unknown-linux-musl,$(MUSL_MODULE_PACKAGE_SPECS),$(call musl_modules_dist_dir,x86_64,debug)) + +musl-x86_64-modules-dist: + $(call check_present,x86_64-linux-musl-gcc) + cargo build --release --workspace $(MUSL_WORKSPACE_EXCLUDES) --target x86_64-unknown-linux-musl + $(call stage_profile_modules,release,x86_64-unknown-linux-musl) + $(call stage_modules_dist_from,release,x86_64-unknown-linux-musl,$(MUSL_MODULE_PACKAGE_SPECS),$(call musl_modules_dist_dir,x86_64,release)) + +musl-aarch64-modules-dist-dev: + $(call check_present,aarch64-linux-musl-gcc) + cargo build -v --workspace $(MUSL_WORKSPACE_EXCLUDES) --target aarch64-unknown-linux-musl + $(call stage_profile_modules,debug,aarch64-unknown-linux-musl) + $(call stage_modules_dist_from,debug,aarch64-unknown-linux-musl,$(MUSL_MODULE_PACKAGE_SPECS),$(call musl_modules_dist_dir,aarch64,debug)) + +musl-aarch64-modules-dist: + $(call check_present,aarch64-linux-musl-gcc) + cargo build --release --workspace $(MUSL_WORKSPACE_EXCLUDES) --target aarch64-unknown-linux-musl + $(call stage_profile_modules,release,aarch64-unknown-linux-musl) + $(call stage_modules_dist_from,release,aarch64-unknown-linux-musl,$(MUSL_MODULE_PACKAGE_SPECS),$(call musl_modules_dist_dir,aarch64,release)) + all-dev: @scripts/maybe-mxrun.sh all-dev || $(MAKE) _all_dev diff --git a/Makefile.in b/Makefile.in index eaa7202b..b979ccb4 100644 --- a/Makefile.in +++ b/Makefile.in @@ -152,14 +152,15 @@ define stage_profile_modules if [ -f "$$build_dir/service" ]; then cp -f "$$build_dir/service" "$$stage_dir/sys/service" && chmod +x "$$stage_dir/sys/service"; fi; \ if [ -f "$$build_dir/pkg" ]; then cp -f "$$build_dir/pkg" "$$stage_dir/sys/pkg" && chmod +x "$$stage_dir/sys/pkg"; fi; \ if [ -f "$$build_dir/ipfw" ]; then cp -f "$$build_dir/ipfw" "$$stage_dir/net/ipfw" && chmod +x "$$stage_dir/net/ipfw"; fi; \ - if [ -f "$build_dir/http-mod" ]; then cp -f "$build_dir/http-mod" "$stage_dir/net/http" && chmod +x "$stage_dir/net/http"; fi; \ - + if [ -f "$$build_dir/http-mod" ]; then cp -f "$$build_dir/http-mod" "$$stage_dir/net/http" && chmod +x "$$stage_dir/net/http"; fi; \ if [ -f "$$build_dir/file" ]; then cp -f "$$build_dir/file" "$$stage_dir/fs/file" && chmod +x "$$stage_dir/fs/file"; fi; \ if [ -f "$$build_dir/dir" ]; then cp -f "$$build_dir/dir" "$$stage_dir/fs/dir" && chmod +x "$$stage_dir/fs/dir"; fi; \ if [ -f "$$build_dir/lua-runtime" ]; then cp -f "$$build_dir/lua-runtime" "$$stage_dir/runtime/lua-runtime" && chmod +x "$$stage_dir/runtime/lua-runtime"; fi; \ if [ -f "$$build_dir/py3-runtime" ]; then cp -f "$$build_dir/py3-runtime" "$$stage_dir/runtime/py3-runtime" && chmod +x "$$stage_dir/runtime/py3-runtime"; fi; \ if [ -f "$$build_dir/wasm-runtime" ]; then cp -f "$$build_dir/wasm-runtime" "$$stage_dir/runtime/wasm-runtime" && chmod +x "$$stage_dir/runtime/wasm-runtime"; fi; \ - if [ -f "$$build_dir/resource" ]; then cp -f "$$build_dir/resource" "$$stage_dir/cfg/resource" && chmod +x "$$stage_dir/cfg/resource"; fi + if [ -f "$$build_dir/resource" ]; then cp -f "$$build_dir/resource" "$$stage_dir/cfg/resource" && chmod +x "$$stage_dir/cfg/resource"; fi; \ + if [ -f "$$build_dir/facts" ]; then cp -f "$$build_dir/facts" "$$stage_dir/facts" && chmod +x "$$stage_dir/facts"; fi; \ + if [ -f "$$build_dir/kernel" ]; then cp -f "$$build_dir/kernel" "$$stage_dir/kernel" && chmod +x "$$stage_dir/kernel"; fi endef define stage_profile_minion @@ -215,7 +216,7 @@ service) rel=sys/service ;; \ endef define stage_modules_dist_from - @dist=$(MODULES_DIST_DIR); \ + @dist=$$(if [ -n "$(4)" ]; then echo $(4); else echo $(MODULES_DIST_DIR); fi); \ stage_dir=$$(if [ -n "$(2)" ]; then echo $(STAGE_ROOT)/$(2)/$(1); else echo $(STAGE_ROOT)/native/$(1); fi); \ rm -rf "$$dist"; \ mkdir -p "$$dist"; \ @@ -230,6 +231,7 @@ define stage_modules_dist_from net) rel=sys/net ;; \ run) rel=sys/run ;; \ ssrun) rel=sys/ssrun ;; \ + pkg) rel=sys/pkg ;; \ user) rel=sys/user ;; \ service) rel=sys/service ;; \ http-mod) rel=net/http ;; \ @@ -269,6 +271,7 @@ define stage_native_modules_dist net) rel=sys/net ;; \ run) rel=sys/run ;; \ ssrun) rel=sys/ssrun ;; \ + pkg) rel=sys/pkg ;; \ user) rel=sys/user ;; \ service) rel=sys/service ;; \ http-mod) rel=net/http ;; \ @@ -349,3 +352,6 @@ define refresh_current_minion_repo "$$sysbin" module -R -t --name $(CURRENT_MINION_SLOT) || true; \ "$$sysbin" module -A -t -p "$$minion" endef +define musl_modules_dist_dir +build/musl-$(1)-modules-dist$(if $(filter debug,$(2)),-dev,) +endef diff --git a/libmodpak/src/mpk.rs b/libmodpak/src/mpk.rs index ee0b7d39..55691c09 100644 --- a/libmodpak/src/mpk.rs +++ b/libmodpak/src/mpk.rs @@ -860,6 +860,30 @@ impl ModPakMetadata { Ok(mpm) } + /// Build metadata from a parsed spec and binary path (no CLI). + pub fn from_spec(spec: &ModInterface, path: std::path::PathBuf) -> Result { + let mut mpm = ModPakMetadata { + path, + name: spec.name().to_string(), + descr: spec.description().to_string().replace('\n', " "), + version: if spec.version().is_empty() { None } else { Some(spec.version().to_string()) }, + author: if spec.author().is_empty() { None } else { Some(spec.author().to_string()) }, + manpage: spec.manpage().map(|s| s.to_string()), + ..Default::default() + }; + mpm.load_args(spec.arguments().to_vec()); + mpm.load_opts(spec.options().to_vec()); + + if mpm.name.is_empty() { + return Err(SysinspectError::InvalidModuleName("Module name is empty in spec".to_string())); + } + if mpm.descr.is_empty() { + return Err(SysinspectError::InvalidModuleName("Module description is empty in spec".to_string())); + } + mpm.validate_namespace()?; + Ok(mpm) + } + pub(crate) fn get_descr(&self) -> &str { &self.descr } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index ca6aa97c..86f65747 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,4 +1,4 @@ -use crate::{MEM_LOGGER, call_master_console, ui::elements::DbListItem}; +use crate::{call_master_console, ui::elements::DbListItem}; use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; use elements::{ActiveBox, AlertResult, CycleListItem, EventListItem, MinionListItem}; use indexmap::IndexMap; @@ -8,9 +8,11 @@ use libeventreg::{ ipcc::DbIPCClient, kvdb::{EventData, EventMinion, EventSession}, }; +use libmodcore::modinit::ModInterface; +use libmodpak::{SysInspectModPak, mpk::ModPakMetadata}; use libsysinspect::{ cfg::mmconf::MasterConfig, - console::{ConsoleMinionInfoRow, ConsoleModelRow, ConsoleOnlineMinionRow, ConsolePayload}, + console::{ConsoleMinionInfoRow, ConsoleModelRow, ConsoleModuleRow, ConsoleOnlineMinionRow, ConsolePayload}, }; use libsysproto::query::{ SCHEME_COMMAND, @@ -59,11 +61,6 @@ pub async fn run(cfg: MasterConfig, config_found: bool) -> io::Result<()> { let result = tokio_run(cfg, config_found, &mut terminal).await; ratatui::restore(); - if !MEM_LOGGER.get_messages().is_empty() { - println!("Memory log:"); - println!("{:#?}", MEM_LOGGER.get_messages()); - } - result } @@ -610,7 +607,8 @@ impl SysInspectUX { fn on_events(&mut self) -> io::Result<()> { self.sync_main_focus_for_overlays(); - if event::poll(Duration::from_secs(1))? { + let poll_dur = if self.repo_manager.progress.lock().unwrap().is_some() { Duration::from_millis(50) } else { Duration::from_secs(1) }; + if event::poll(poll_dur)? { if let Event::Key(e) = event::read()? && e.kind == KeyEventKind::Press { @@ -647,6 +645,20 @@ impl SysInspectUX { { self.process_module_add(&path); } + // Detect progress bar completion for bulk add + if self.repo_manager.visible { + let p = self.repo_manager.progress.lock().unwrap(); + if p.is_none() { + drop(p); + if self.repo_manager.needs_reload { + self.repo_manager.needs_reload = false; + let _ = self.load_module_index(); + } + } else { + // Track that a reload is needed when progress finishes + self.repo_manager.needs_reload = true; + } + } Ok(()) } @@ -1724,7 +1736,8 @@ impl SysInspectUX { }) .map_err(|e| format!("Failed to get module index: {e}"))?; match resp.payload { - ConsolePayload::MasterModuleIndex { rows } => { + ConsolePayload::MasterModuleIndex { mut rows } => { + rows.sort_by(|a, b| a.name.cmp(&b.name)); self.repo_manager.rows = rows; self.repo_manager.cursor = 0; Ok(()) @@ -1738,11 +1751,35 @@ impl SysInspectUX { return false; } if self.repo_manager.staging { - return self.repo_manager.handle_staging_key(e); + let handled = self.repo_manager.handle_staging_key(e); + if self.repo_manager.bulk_add_triggered { + self.repo_manager.bulk_add_triggered = false; + let checked: Vec<_> = self.repo_manager.staged.iter().filter(|m| m.checked).cloned().collect(); + if checked.is_empty() { + self.error_alert_visible = true; + self.error_alert_message = "No modules selected".to_string(); + } else { + self.repo_manager.exit_staging(); + self.bulk_add_modules(checked); + } + } + if self.repo_manager.bulk_delete_triggered { + self.repo_manager.bulk_delete_triggered = false; + let checked: Vec<_> = self.repo_manager.staged.iter().filter(|m| m.checked).map(|m| m.name.clone()).collect(); + if checked.is_empty() { + self.error_alert_visible = true; + self.error_alert_message = "No modules selected".to_string(); + } else { + self.repo_manager.exit_staging(); + self.bulk_delete_modules(&checked); + } + } + return handled; } let page = 10usize; match e.code { KeyCode::Esc => { + self.repo_manager.exit_staging(); self.repo_manager.visible = false; } KeyCode::Up => { @@ -1759,8 +1796,24 @@ impl SysInspectUX { } KeyCode::Enter => {} // placeholder KeyCode::Delete => { - self.error_alert_visible = true; - self.error_alert_message = "Not implemented yet".to_string(); + if !self.repo_manager.rows.is_empty() { + self.repo_manager.delete_mode = true; + self.repo_manager.staged = self + .repo_manager + .rows + .iter() + .map(|r| repomanager::StagedModule { + name: r.name.clone(), + version: r.version.clone(), + descr: r.descr.clone(), + path: std::path::PathBuf::new(), + checked: false, + }) + .collect(); + self.repo_manager.staging = true; + self.repo_manager.staging_cursor = 0; + self.repo_manager.staging_focus = repomanager::StagingFocus::List; + } } KeyCode::Insert | KeyCode::Char('i') if !e.modifiers.contains(KeyModifiers::CONTROL) => { self.file_picker.open(&std::env::current_dir().unwrap_or_default(), filepicker::PickerMode::Any); @@ -1776,12 +1829,20 @@ impl SysInspectUX { fn process_module_add(&mut self, path: &std::path::Path) { if path.is_dir() { - let staged = Self::scan_dir_for_modules(path); + let mut staged = Self::scan_dir_for_modules(path); if staged.is_empty() { self.error_alert_visible = true; self.error_alert_message = "No .spec files found in the selected directory".to_string(); } else { - self.repo_manager.enter_staging(staged); + let total = staged.len(); + Self::dedup_staged_modules(&self.repo_manager.rows, &mut staged); + let skipped = total - staged.len(); + if staged.is_empty() { + self.error_alert_visible = true; + self.error_alert_message = format!("No new modules found, {skipped} skipped"); + } else { + self.repo_manager.enter_staging(staged); + } } } else { let spec = path.with_extension("spec"); @@ -1810,18 +1871,105 @@ impl SysInspectUX { if !sub.is_dir() { continue; } - let module_name = sub.file_name().unwrap_or_default().to_string_lossy().to_string(); - let spec = sub.join(format!("{module_name}.spec")); + let dir_name = sub.file_name().unwrap_or_default().to_string_lossy().to_string(); + let spec = sub.join(format!("{dir_name}.spec")); if !spec.exists() { continue; } + let module_name = Self::read_spec_name(&spec).unwrap_or_else(|| dir_name.clone()); let (version, descr) = Self::read_spec_version_descr(&spec); - let bin = sub.join(&module_name); + let bin = sub.join(&dir_name); staged.push(repomanager::StagedModule { name: module_name, version, descr, path: if bin.exists() { bin } else { spec }, checked: true }); } staged } + fn read_spec_name(spec: &std::path::Path) -> Option { + match std::fs::read_to_string(spec) { + Ok(yaml) => match serde_yaml::from_str::(&yaml) { + Ok(v) => v.get("name").and_then(|v| v.as_str()).map(|s| s.to_string()), + Err(_) => None, + }, + Err(_) => None, + } + } + + fn dedup_staged_modules(existing: &[ConsoleModuleRow], staged: &mut Vec) { + staged.retain(|m| !existing.iter().any(|r| r.name == m.name && r.version == m.version)); + } + + fn bulk_add_modules(&mut self, staged: Vec) { + let total = staged.len(); + *self.repo_manager.progress.lock().unwrap() = Some((0, total)); + let progress = self.repo_manager.progress.clone(); + let repo_root = self.cfg.fileserver_root().join("repo"); + + std::thread::spawn(move || { + let mut repo = match SysInspectModPak::new(repo_root) { + Ok(r) => r, + Err(e) => { + *progress.lock().unwrap() = None; + // Can't access self.error_alert here — just log + log::error!("Cannot open repository: {e}"); + return; + } + }; + for (i, m) in staged.iter().enumerate() { + let spec_path = m.path.with_extension("spec"); + let spec_yaml = match std::fs::read_to_string(&spec_path) { + Ok(y) => y, + Err(e) => { + log::error!("Cannot read spec {}: {e}", m.name); + *progress.lock().unwrap() = None; + return; + } + }; + let mi: ModInterface = match serde_yaml::from_str(&spec_yaml) { + Ok(mi) => mi, + Err(e) => { + log::error!("Invalid spec {}: {e}", m.name); + *progress.lock().unwrap() = None; + return; + } + }; + let meta = match ModPakMetadata::from_spec(&mi, m.path.clone()) { + Ok(meta) => meta, + Err(e) => { + log::error!("Invalid spec data {}: {e}", m.name); + *progress.lock().unwrap() = None; + return; + } + }; + if let Err(e) = repo.add_module(meta) { + log::error!("Cannot add module {}: {e}", m.name); + *progress.lock().unwrap() = None; + return; + } + *progress.lock().unwrap() = Some((i + 1, total)); + } + *progress.lock().unwrap() = None; + }); + } + + fn bulk_delete_modules(&mut self, names: &[String]) { + let repo_root = self.cfg.fileserver_root().join("repo"); + let mut repo = match SysInspectModPak::new(repo_root) { + Ok(r) => r, + Err(e) => { + self.error_alert_visible = true; + self.error_alert_message = format!("Cannot open repository: {e}"); + return; + } + }; + let name_refs: Vec<&str> = names.iter().map(|s| s.as_str()).collect(); + if let Err(e) = repo.remove_module(name_refs) { + self.error_alert_visible = true; + self.error_alert_message = format!("Cannot remove modules: {e}"); + } else { + let _ = self.load_module_index(); + } + } + fn read_spec_version_descr(spec: &std::path::Path) -> (Option, String) { match std::fs::read_to_string(spec) { Ok(yaml) => match serde_yaml::from_str::(&yaml) { diff --git a/src/ui/repomanager.rs b/src/ui/repomanager.rs index dfb2a9a4..7bed9fb5 100644 --- a/src/ui/repomanager.rs +++ b/src/ui/repomanager.rs @@ -10,7 +10,10 @@ use ratatui::{ widgets::{Block, BorderType, Borders, Clear, Widget}, }; use ratatui_glamour::color::blend_2d; -use std::cell::Cell; +use std::{ + cell::Cell, + sync::{Arc, Mutex}, +}; #[derive(Debug, Clone)] pub struct StagedModule { @@ -41,6 +44,15 @@ pub struct RepoManager { pub staging_cursor: usize, pub staging_scroll: Cell, pub staging_focus: StagingFocus, + + // Progress + pub progress: Arc>>, + + // Signals + pub bulk_add_triggered: bool, + pub bulk_delete_triggered: bool, + pub delete_mode: bool, + pub needs_reload: bool, } impl Default for RepoManager { @@ -55,6 +67,11 @@ impl Default for RepoManager { staging_cursor: 0, staging_scroll: Cell::new(0), staging_focus: StagingFocus::List, + progress: Arc::new(Mutex::new(None)), + bulk_add_triggered: false, + bulk_delete_triggered: false, + delete_mode: false, + needs_reload: false, } } } @@ -70,6 +87,7 @@ impl RepoManager { pub fn exit_staging(&mut self) { self.staging = false; + self.delete_mode = false; self.staged.clear(); } @@ -110,8 +128,11 @@ impl RepoManager { } crossterm::event::KeyCode::Enter => match self.staging_focus { StagingFocus::AddSelected => { - // TODO: bulk register checked modules - self.exit_staging(); + if self.delete_mode { + self.bulk_delete_triggered = true; + } else { + self.bulk_add_triggered = true; + } } StagingFocus::Cancel => { self.exit_staging(); @@ -127,7 +148,9 @@ impl RepoManager { if !self.visible { return; } - if self.staging { + if self.progress.lock().unwrap().is_some() { + self.render_progress(parent, buf); + } else if self.staging { self.render_staging(parent, buf); } else { self.render_main(parent, buf); @@ -188,9 +211,7 @@ impl RepoManager { s = s.min(max_scroll); self.scroll.set(s); - let muted = Style::default().fg(palette::MUTED); let hl = Style::default().fg(palette::BLACK).bg(palette::HIGHLIGHT); - let dim_hl = Style::default().fg(palette::BG_0).bg(palette::HIGHLIGHT); for i in 0..view_h.min(total.saturating_sub(s)) { let idx = s + i; @@ -198,15 +219,27 @@ impl RepoManager { let row = &self.rows[idx]; let selected = idx == cursor; let row_style = if selected { hl } else { Style::default().fg(palette::FG) }; - let dim = if selected { dim_hl } else { muted }; + + // Fill entire row with highlight background when selected + if selected { + for cx in 0..inner.width { + if let Some(cell) = buf.cell_mut(Position::new(inner.x + cx, ry)) { + cell.set_bg(palette::HIGHLIGHT); + } + } + } let name = truncate_str(&row.name, name_w as usize); buf.set_string(inner.x + 1, ry, format!(" {name}"), row_style); + let ver = row.version.as_deref().unwrap_or("—"); - buf.set_string(inner.x + 1 + name_w + 1, ry, truncate_str(ver, ver_w as usize), dim); + let ver_style = if selected { row_style } else { Style::default().fg(palette::HIGHLIGHT) }; + buf.set_string(inner.x + 1 + name_w + 1, ry, truncate_str(ver, ver_w as usize), ver_style); + let desc_x = inner.x + 1 + name_w + 1 + ver_w + 1; let max_desc = desc_w.saturating_sub(1) as usize; - buf.set_string(desc_x, ry, truncate_str(&row.descr, max_desc), dim); + let desc_style = if selected { row_style } else { Style::default().fg(palette::GRAY_1) }; + buf.set_string(desc_x, ry, truncate_str(&row.descr, max_desc), desc_style); } if total > view_h { @@ -343,21 +376,87 @@ impl RepoManager { // Buttons let btn_y = inner.y + list_height + 1; - let add_label = "[ Add Selected ]"; + let action_label = if self.delete_mode { "[ Delete ]" } else { "[ Add Selected ]" }; let cancel_label = "[ Cancel ]"; - let add_w = add_label.len() as u16; + let action_w = action_label.len() as u16; let cancel_w = cancel_label.len() as u16; - let total_btn_w = add_w + cancel_w + 6; + let total_btn_w = action_w + cancel_w + 6; let btn_x = inner.x + (inner.width.saturating_sub(total_btn_w)) / 2; let sel_btn = Style::default().fg(palette::WHITE).bg(palette::PROCESSING_HEAT).add_modifier(Modifier::BOLD); let unsel_btn = Style::default().fg(palette::FG).bg(palette::BG_2).add_modifier(Modifier::BOLD); - let add_style = if self.staging_focus == StagingFocus::AddSelected { sel_btn } else { unsel_btn }; + let action_style = if self.staging_focus == StagingFocus::AddSelected { sel_btn } else { unsel_btn }; let cancel_style = if self.staging_focus == StagingFocus::Cancel { sel_btn } else { unsel_btn }; - buf.set_string(btn_x, btn_y, add_label, add_style); - buf.set_string(btn_x + add_w + 4, btn_y, cancel_label, cancel_style); + buf.set_string(btn_x, btn_y, action_label, action_style); + buf.set_string(btn_x + action_w + 4, btn_y, cancel_label, cancel_style); + + Self::draw_shadow(buf, canvas, dlg_w, dlg_h); + } + + fn render_progress(&self, parent: Rect, buf: &mut Buffer) { + let (done, total) = match *self.progress.lock().unwrap() { + Some(p) => p, + None => return, + }; + + let dlg_w = (parent.width / 2).clamp(50, 80); + let dlg_h = 6u16; + let x = parent.x + (parent.width.saturating_sub(dlg_w)) / 2; + let y = parent.y + (parent.height.saturating_sub(dlg_h)) / 2; + let canvas = Rect { x, y, width: dlg_w, height: dlg_h }; + + Clear.render(canvas, buf); + + let grad = blend_2d(canvas.width as usize, canvas.height as usize, 13.0, &[palette::GRAY_0, palette::PROCESSING_GLOW] as &[Color]); + for row in 0..canvas.height { + for col in 0..canvas.width { + let idx = row as usize * canvas.width as usize + col as usize; + if let Some(cell) = buf.cell_mut(Position::new(canvas.x + col, canvas.y + row)) { + cell.set_bg(grad[idx]); + } + } + } + + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(palette::PROCESSING_GLOW)) + .style(Style::default()); + let inner = block.inner(canvas); + block.render(canvas, buf); + + let title_style = TitleStyle::cyberpunk(palette::PROCESSING_GLOW); + title::overlay_gradient_title( + buf, + canvas, + &title_style, + &[TitleSegment { text: " Adding Modules ".into(), bg: palette::PROCESSING_BASE, fg: palette::FG }], + ); + + let bar_y = inner.y + 1; + let bar_w = inner.width.saturating_sub(2); + let filled = (bar_w as usize * done).checked_div(total).map(|v| v as u16).unwrap_or(0); + + // Draw filled and unfilled portions + if filled > 0 { + buf.set_string(inner.x + 1, bar_y, "█".repeat(filled as usize), Style::default().fg(palette::PROCESSING_PEAK)); + } + if filled < bar_w { + let unfilled = (bar_w - filled) as usize; + buf.set_string(inner.x + 1 + filled, bar_y, "─".repeat(unfilled), Style::default().fg(palette::MUTED)); + } + + // Percentage + let pct = (done * 100).checked_div(total).map(|p| format!("{p}%")).unwrap_or_else(|| "0%".into()); + let pct_x = inner.x + (inner.width.saturating_sub(pct.len() as u16)) / 2; + buf.set_string(pct_x, bar_y, &pct, Style::default().fg(palette::FG).add_modifier(Modifier::BOLD)); + + // Cancel button + let cancel = "[ Cancel ]"; + let btn_x = inner.x + (inner.width.saturating_sub(cancel.len() as u16)) / 2; + buf.set_string(btn_x, bar_y + 1, cancel, Style::default().fg(palette::FG).bg(palette::BG_2).add_modifier(Modifier::BOLD)); Self::draw_shadow(buf, canvas, dlg_w, dlg_h); } From 818bd5452a725ac63f281ed0ae38dde34884ad0b Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Thu, 11 Jun 2026 22:28:40 +0200 Subject: [PATCH 11/25] Fix tests --- libmodpak/src/lib_ut.rs | 4 +++- libmodpak/src/mpk_ut.rs | 5 +++-- libmodpak/tests/profile_sync.rs | 2 +- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/libmodpak/src/lib_ut.rs b/libmodpak/src/lib_ut.rs index b234333f..5a9a1d17 100644 --- a/libmodpak/src/lib_ut.rs +++ b/libmodpak/src/lib_ut.rs @@ -55,7 +55,9 @@ mod tests { let dst = repo.root.join("script").join(platform).join(arch).join(subpath); fs::create_dir_all(dst.parent().expect("module parent should exist")).expect("module parent should be created"); fs::write(&dst, format!("content for {name}")).expect("module file should be written"); - repo.idx.index_module(name, subpath, platform, arch, "demo module", false, "deadbeef", None, None).expect("module should be indexed"); + repo.idx + .index_module(name, subpath, platform, arch, "demo module", false, "deadbeef", None, None, None, None, None) + .expect("module should be indexed"); fs::write(repo.root.join("mod.index"), repo.idx.to_yaml().expect("index should serialize")).expect("index file should be written"); } diff --git a/libmodpak/src/mpk_ut.rs b/libmodpak/src/mpk_ut.rs index 29583ec2..af59dc83 100644 --- a/libmodpak/src/mpk_ut.rs +++ b/libmodpak/src/mpk_ut.rs @@ -52,9 +52,10 @@ library: "#, ) .expect("repo index should deserialize"); - repo.index_module("runtime.lua", "runtime/lua", "any", "noarch", "lua runtime", false, "deadbeef", None, None) + repo.index_module("runtime.lua", "runtime/lua", "any", "noarch", "lua runtime", false, "deadbeef", None, None, None, None, None) .expect("runtime module should index"); - repo.index_module("net.ping", "net/ping", "any", "noarch", "ping module", false, "cafebabe", None, None).expect("ping module should index"); + repo.index_module("net.ping", "net/ping", "any", "noarch", "ping module", false, "cafebabe", None, None, None, None, None) + .expect("ping module should index"); let filtered = repo.retain_profiles(&modules, &libraries); let modules = filtered.modules(); diff --git a/libmodpak/tests/profile_sync.rs b/libmodpak/tests/profile_sync.rs index cc6d7ca6..a5295249 100644 --- a/libmodpak/tests/profile_sync.rs +++ b/libmodpak/tests/profile_sync.rs @@ -30,7 +30,7 @@ fn set_script_modules(root: &Path, modules: &[&str]) { }; for module in modules { index - .index_module(module, &module.replace('.', "/"), "any", "noarch", "demo module", false, "deadbeef", None, None) + .index_module(module, &module.replace('.', "/"), "any", "noarch", "demo module", false, "deadbeef", None, None, None, None, None) .expect("module should index"); } fs::write(root.join("mod.index"), index.to_yaml().expect("mod.index should serialize")).expect("mod.index should write"); From 56c9c79389238b76a30779b1b8dd13114fc6b734 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Fri, 12 Jun 2026 13:07:46 +0200 Subject: [PATCH 12/25] Add shortcuts to the popup menu, add filter to the repomanager list --- src/ui/macts.rs | 8 +- src/ui/mod.rs | 157 ++++++++++++++++++++++++++++++++++++- src/ui/repomanager.rs | 178 ++++++++++++++++++++++++++++-------------- 3 files changed, 281 insertions(+), 62 deletions(-) diff --git a/src/ui/macts.rs b/src/ui/macts.rs index 8623eedc..e3a7d198 100644 --- a/src/ui/macts.rs +++ b/src/ui/macts.rs @@ -32,9 +32,9 @@ const MENU_SECTIONS: &[MenuSection] = &[ const MASTER_MENU_SECTIONS: &[MenuSection] = &[ MenuSection { title: "Operations", - items: &[("View master logs online", ""), ("View local logs", ""), ("Register a minion", ""), ("Repository manager", "")], + items: &[("View master logs online", "^O"), ("View local logs", "^L"), ("Register a minion", "^R"), ("Repository manager", "^G")], }, - MenuSection { title: "System", items: &[("Start", ""), ("Stop", ""), ("Restart", "")] }, + MenuSection { title: "System", items: &[("Start", "^T"), ("Stop", "^S"), ("Restart", "^E")] }, ]; pub(crate) fn total_menu_items() -> usize { @@ -215,9 +215,11 @@ impl SysInspectUX { let max_item_w = max_label_w + 20; let title_style = TitleStyle::cyberpunk(palette::PROCESSING_GLOW); + let is_system = self.master_menu_sel >= 4; + let sub_title = if is_system { " System " } else { " Operations " }; let segments = vec![ TitleSegment { text: " Master ".into(), bg: palette::PROCESSING_GLOW, fg: palette::FG }, - TitleSegment { text: " Operations ".into(), bg: palette::PROCESSING_HEAT, fg: palette::FG }, + TitleSegment { text: sub_title.into(), bg: palette::PROCESSING_HEAT, fg: palette::FG }, ]; let local_logs_available = self.cfg.logfile_std().exists() || self.cfg.logfile_err().exists(); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 86f65747..68f0310a 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1776,23 +1776,60 @@ impl SysInspectUX { } return handled; } + if self.repo_manager.filter_focus { + match e.code { + KeyCode::Esc => { + self.repo_manager.filter_focus = false; + self.repo_manager.cursor = 0; + } + KeyCode::Tab | KeyCode::BackTab => { + self.repo_manager.filter_focus = false; + self.repo_manager.cursor = 0; + } + KeyCode::Backspace => { + self.repo_manager.filter.delete_before(); + } + KeyCode::Delete => { + self.repo_manager.filter.delete_at(); + } + KeyCode::Left => { + self.repo_manager.filter.move_left(); + } + KeyCode::Right => { + self.repo_manager.filter.move_right(); + } + KeyCode::Home => { + self.repo_manager.filter.home(); + } + KeyCode::End => { + self.repo_manager.filter.end(); + } + KeyCode::Char(c) => { + self.repo_manager.filter.insert_char(c); + } + _ => {} + } + return true; + } + let max_cursor = || self.repo_filtered_count().saturating_sub(1); let page = 10usize; match e.code { KeyCode::Esc => { self.repo_manager.exit_staging(); self.repo_manager.visible = false; + self.status_at_cycles(); } KeyCode::Up => { self.repo_manager.cursor = self.repo_manager.cursor.saturating_sub(1); } KeyCode::Down => { - self.repo_manager.cursor = (self.repo_manager.cursor + 1).min(self.repo_manager.rows.len().saturating_sub(1)); + self.repo_manager.cursor = (self.repo_manager.cursor + 1).min(max_cursor()); } KeyCode::PageUp => { self.repo_manager.cursor = self.repo_manager.cursor.saturating_sub(page); } KeyCode::PageDown => { - self.repo_manager.cursor = (self.repo_manager.cursor + page).min(self.repo_manager.rows.len().saturating_sub(1)); + self.repo_manager.cursor = (self.repo_manager.cursor + page).min(max_cursor()); } KeyCode::Enter => {} // placeholder KeyCode::Delete => { @@ -1822,11 +1859,22 @@ impl SysInspectUX { self.error_alert_visible = true; self.error_alert_message = "Not implemented yet".to_string(); } + KeyCode::Tab => { + self.repo_manager.filter_focus = true; + } + KeyCode::Char('/') if !e.modifiers.contains(KeyModifiers::CONTROL) => { + self.repo_manager.filter_focus = true; + } _ => {} } true } + fn repo_filtered_count(&self) -> usize { + let f = self.repo_manager.filter.value().to_lowercase(); + self.repo_manager.rows.iter().filter(|r| f.is_empty() || r.name.to_lowercase().contains(&f) || r.descr.to_lowercase().contains(&f)).count() + } + fn process_module_add(&mut self, path: &std::path::Path) { if path.is_dir() { let mut staged = Self::scan_dir_for_modules(path); @@ -2133,6 +2181,63 @@ impl SysInspectUX { self.master_menu_visible = false; self.status_at_cycles(); } + KeyCode::Char('o') if e.modifiers.contains(KeyModifiers::CONTROL) => { + self.master_menu_visible = false; + if self.evtipc.is_some() { + self.open_master_logs(); + } else { + self.error_alert_visible = true; + self.error_alert_message = "Master is not running".to_string(); + } + } + KeyCode::Char('l') if e.modifiers.contains(KeyModifiers::CONTROL) => { + self.master_menu_visible = false; + match self.load_master_logs_local() { + Ok(()) => { + self.master_logs_visible = true; + self.master_logs_tab = 0; + self.master_logs_polling = false; + self.status_at_master_logs(); + } + Err(e) => { + self.error_alert_visible = true; + self.error_alert_message = e; + } + } + } + KeyCode::Char('r') if e.modifiers.contains(KeyModifiers::CONTROL) => { + self.master_menu_visible = false; + self.error_alert_visible = true; + self.error_alert_message = "Not implemented yet".to_string(); + } + KeyCode::Char('g') if e.modifiers.contains(KeyModifiers::CONTROL) => { + self.master_menu_visible = false; + if let Err(err) = self.load_module_index() { + self.error_alert_visible = true; + self.error_alert_message = err; + } else { + self.repo_manager.visible = true; + self.status_at_repo_manager(); + } + } + KeyCode::Char('t') if e.modifiers.contains(KeyModifiers::CONTROL) => { + self.master_menu_visible = false; + self.master_confirm_visible = true; + self.master_confirm_choice = AlertResult::Default; + self.master_confirm_action = 1; + } + KeyCode::Char('s') if e.modifiers.contains(KeyModifiers::CONTROL) => { + self.master_menu_visible = false; + self.master_confirm_visible = true; + self.master_confirm_choice = AlertResult::Default; + self.master_confirm_action = 3; + } + KeyCode::Char('e') if e.modifiers.contains(KeyModifiers::CONTROL) => { + self.master_menu_visible = false; + self.master_confirm_visible = true; + self.master_confirm_choice = AlertResult::Default; + self.master_confirm_action = 2; + } KeyCode::Up => { self.master_menu_sel = self.master_menu_sel.saturating_sub(1); } @@ -2733,6 +2838,54 @@ impl SysInspectUX { self.error_alert_message = format!("Failed to load models: {err}"); } }, + KeyCode::Char('o') if e.modifiers.contains(KeyModifiers::CONTROL) => { + if self.evtipc.is_some() { + self.open_master_logs(); + } else { + self.error_alert_visible = true; + self.error_alert_message = "Master is not running".to_string(); + } + } + KeyCode::Char('l') if e.modifiers.contains(KeyModifiers::CONTROL) => match self.load_master_logs_local() { + Ok(()) => { + self.master_logs_visible = true; + self.master_logs_tab = 0; + self.master_logs_polling = false; + self.status_at_master_logs(); + } + Err(e) => { + self.error_alert_visible = true; + self.error_alert_message = e; + } + }, + KeyCode::Char('r') if e.modifiers.contains(KeyModifiers::CONTROL) => { + self.error_alert_visible = true; + self.error_alert_message = "Not implemented yet".to_string(); + } + KeyCode::Char('g') if e.modifiers.contains(KeyModifiers::CONTROL) => { + if let Err(err) = self.load_module_index() { + self.error_alert_visible = true; + self.error_alert_message = err; + } else { + self.repo_manager.visible = true; + self.status_at_repo_manager(); + } + } + KeyCode::Char('t') if e.modifiers.contains(KeyModifiers::CONTROL) => { + self.master_confirm_visible = true; + self.master_confirm_choice = AlertResult::Default; + self.master_confirm_action = 1; + } + KeyCode::Char('s') if e.modifiers.contains(KeyModifiers::CONTROL) => { + self.master_confirm_visible = true; + self.master_confirm_choice = AlertResult::Default; + self.master_confirm_action = 3; + } + KeyCode::Char('e') if e.modifiers.contains(KeyModifiers::CONTROL) => { + self.master_confirm_visible = true; + self.master_confirm_choice = AlertResult::Default; + self.master_confirm_action = 2; + } KeyCode::Char('m') if !e.modifiers.contains(KeyModifiers::CONTROL) => { self.master_menu_visible = true; self.master_menu_sel = 0; diff --git a/src/ui/repomanager.rs b/src/ui/repomanager.rs index 7bed9fb5..a784070b 100644 --- a/src/ui/repomanager.rs +++ b/src/ui/repomanager.rs @@ -4,11 +4,12 @@ use super::{ }; use libsysinspect::console::ConsoleModuleRow; use ratatui::{ - layout::Position, + layout::{Constraint, Direction, Layout, Position}, prelude::{Buffer, Rect}, style::{Color, Modifier, Style}, - widgets::{Block, BorderType, Borders, Clear, Widget}, + widgets::{Block, BorderType, Borders, Clear, StatefulWidget, Widget}, }; +use ratatui_cheese::input::{Input, InputState, InputStyles}; use ratatui_glamour::color::blend_2d; use std::{ cell::Cell, @@ -53,6 +54,10 @@ pub struct RepoManager { pub bulk_delete_triggered: bool, pub delete_mode: bool, pub needs_reload: bool, + + // Filter + pub filter: InputState, + pub filter_focus: bool, } impl Default for RepoManager { @@ -72,6 +77,8 @@ impl Default for RepoManager { bulk_delete_triggered: false, delete_mode: false, needs_reload: false, + filter: InputState::new(), + filter_focus: false, } } } @@ -192,66 +199,94 @@ impl RepoManager { &[TitleSegment { text: " Module and Library Manager ".into(), bg: palette::PROCESSING_BASE, fg: palette::FG }], ); - if inner.height >= 3 && !self.rows.is_empty() { - let name_w: u16 = 28; - let ver_w: u16 = 6; - let desc_w = inner.width.saturating_sub(name_w + ver_w + 2); - - let view_h = inner.height as usize; - let total = self.rows.len(); - let max_scroll = total.saturating_sub(view_h); - let mut s = self.scroll.get(); - let cursor = self.cursor.min(total.saturating_sub(1)); - if cursor < s { - s = cursor; - } - if cursor >= s + view_h { - s = cursor.saturating_sub(view_h.saturating_sub(1)); - } - s = s.min(max_scroll); - self.scroll.set(s); - - let hl = Style::default().fg(palette::BLACK).bg(palette::HIGHLIGHT); - - for i in 0..view_h.min(total.saturating_sub(s)) { - let idx = s + i; - let ry = inner.y + i as u16; - let row = &self.rows[idx]; - let selected = idx == cursor; - let row_style = if selected { hl } else { Style::default().fg(palette::FG) }; - - // Fill entire row with highlight background when selected - if selected { - for cx in 0..inner.width { - if let Some(cell) = buf.cell_mut(Position::new(inner.x + cx, ry)) { - cell.set_bg(palette::HIGHLIGHT); - } - } + if inner.height >= 4 { + // Filter row + let [filter_area, list_area] = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Min(0)]) + .split(inner) + .as_ref() + .try_into() + .unwrap(); + + Self::render_filter_row(filter_area, buf, self.filter_focus, &self.filter); + + if !self.rows.is_empty() { + let inner = list_area; + let name_w: u16 = 28; + let ver_w: u16 = 6; + let desc_w = inner.width.saturating_sub(name_w + ver_w + 2); + + // Build filtered list + let f = self.filter.value().to_lowercase(); + let filtered: Vec<(usize, &ConsoleModuleRow)> = self + .rows + .iter() + .enumerate() + .filter(|(_, r)| f.is_empty() || r.name.to_lowercase().contains(&f) || r.descr.to_lowercase().contains(&f)) + .collect(); + + let view_h = inner.height as usize; + let total = filtered.len(); + let max_scroll = total.saturating_sub(view_h); + let mut s = self.scroll.get(); + let cursor = self.cursor.min(total.saturating_sub(1)); + if cursor < s { + s = cursor; + } + if cursor >= s + view_h { + s = cursor.saturating_sub(view_h.saturating_sub(1)); } + s = s.min(max_scroll); + self.scroll.set(s); + + if total == 0 { + let msg = "(no matches)"; + let x = inner.x + (inner.width.saturating_sub(msg.len() as u16)) / 2; + let y = inner.y + inner.height / 2; + buf.set_string(x, y, msg, Style::default().fg(palette::MUTED)); + } else { + let hl = Style::default().fg(palette::BLACK).bg(palette::HIGHLIGHT); + for i in 0..view_h.min(total.saturating_sub(s)) { + let fi = s + i; + let (_orig_idx, row) = filtered[fi]; + let ry = inner.y + i as u16; + let selected = !self.filter_focus && fi == cursor; + let row_style = if selected { hl } else { Style::default().fg(palette::FG) }; + + if selected { + for cx in 0..inner.width { + if let Some(cell) = buf.cell_mut(Position::new(inner.x + cx, ry)) { + cell.set_bg(palette::HIGHLIGHT); + } + } + } - let name = truncate_str(&row.name, name_w as usize); - buf.set_string(inner.x + 1, ry, format!(" {name}"), row_style); + let name = truncate_str(&row.name, name_w as usize); + buf.set_string(inner.x + 1, ry, format!(" {name}"), row_style); - let ver = row.version.as_deref().unwrap_or("—"); - let ver_style = if selected { row_style } else { Style::default().fg(palette::HIGHLIGHT) }; - buf.set_string(inner.x + 1 + name_w + 1, ry, truncate_str(ver, ver_w as usize), ver_style); + let ver = row.version.as_deref().unwrap_or("—"); + let ver_style = if selected { row_style } else { Style::default().fg(palette::HIGHLIGHT) }; + buf.set_string(inner.x + 1 + name_w + 1, ry, truncate_str(ver, ver_w as usize), ver_style); - let desc_x = inner.x + 1 + name_w + 1 + ver_w + 1; - let max_desc = desc_w.saturating_sub(1) as usize; - let desc_style = if selected { row_style } else { Style::default().fg(palette::GRAY_1) }; - buf.set_string(desc_x, ry, truncate_str(&row.descr, max_desc), desc_style); - } + let desc_x = inner.x + 1 + name_w + 1 + ver_w + 1; + let max_desc = desc_w.saturating_sub(1) as usize; + let desc_style = if selected { row_style } else { Style::default().fg(palette::GRAY_1) }; + buf.set_string(desc_x, ry, truncate_str(&row.descr, max_desc), desc_style); + } - if total > view_h { - let bar_h = ((view_h as f64 / total as f64) * view_h as f64).max(1.0) as usize; - let bar_y = ((s as f64 / total as f64) * (view_h - bar_h) as f64) as usize; - for i in 0..view_h { - let sx = inner.right().saturating_sub(1); - let sy = inner.y + i as u16; - if i >= bar_y && i < bar_y + bar_h { - buf.set_string(sx, sy, "█", Style::default().fg(palette::PROCESSING_HEAT)); - } else { - buf.set_string(sx, sy, "│", Style::default().fg(palette::MUTED)); + if total > view_h { + let bar_h = ((view_h as f64 / total as f64) * view_h as f64).max(1.0) as usize; + let bar_y = ((s as f64 / total as f64) * (view_h - bar_h) as f64) as usize; + for i in 0..view_h { + let sx = inner.right().saturating_sub(1); + let sy = inner.y + i as u16; + if i >= bar_y && i < bar_y + bar_h { + buf.set_string(sx, sy, "█", Style::default().fg(palette::PROCESSING_HEAT)); + } else { + buf.set_string(sx, sy, "│", Style::default().fg(palette::MUTED)); + } + } } } } @@ -461,6 +496,35 @@ impl RepoManager { Self::draw_shadow(buf, canvas, dlg_w, dlg_h); } + fn render_filter_row(area: Rect, buf: &mut Buffer, focused: bool, filter_state: &InputState) { + let label_style = if focused { Style::default().fg(palette::ACCENT) } else { Style::default().fg(palette::MUTED) }; + buf.set_string(area.x, area.y, "filter: ", label_style); + + let input_x = area.x + 8u16; + let input_w = area.width.saturating_sub(8); + if input_w == 0 { + return; + } + + let field_bg = if focused { palette::HIGHLIGHT } else { palette::GRAY_1 }; + for cx in input_x..input_x + input_w { + if let Some(cell) = buf.cell_mut(Position::new(cx, area.y)) { + cell.set_bg(field_bg); + } + } + + let mut is = InputState::new(); + is.set_value(filter_state.value().to_string()); + is.set_focused(focused); + let fc = filter_state.cursor_pos(); + while is.cursor_pos() < fc { + is.move_right(); + } + let styles = InputStyles { text: Style::default().fg(palette::BG_1), ..Default::default() }; + let inp = Input::new("").prompt("").placeholder("search name/description...").styles(styles); + StatefulWidget::render(&inp, Rect::new(input_x, area.y, input_w, 1), buf, &mut is); + } + fn draw_shadow(buf: &mut Buffer, canvas: Rect, dlg_w: u16, dlg_h: u16) { let buf_area = buf.area(); let x = canvas.x; From 4e5f9bd9f977fd34bd839eec9eb0e289fc3bf404 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Fri, 12 Jun 2026 16:54:03 +0200 Subject: [PATCH 13/25] Render colorful manuals --- libmodpak/src/mpk.rs | 10 +- libsysinspect/src/console/mod.rs | 17 ++ src/ui/dslbrowser.rs | 2 +- src/ui/mod.rs | 13 +- src/ui/repomanager.rs | 418 ++++++++++++++++++++++++++++++- sysmaster/src/console.rs | 26 +- 6 files changed, 474 insertions(+), 12 deletions(-) diff --git a/libmodpak/src/mpk.rs b/libmodpak/src/mpk.rs index 55691c09..fea8ac9e 100644 --- a/libmodpak/src/mpk.rs +++ b/libmodpak/src/mpk.rs @@ -620,17 +620,17 @@ impl ModPakRepoIndex { #[derive(Debug, Serialize, Deserialize, Default, Clone)] pub struct ModPackArgument { - name: String, - description: String, + pub name: String, + pub description: String, #[serde(skip_serializing_if = "Option::is_none")] - argtype: Option, // data type: str, int, bool, float, list etc + pub argtype: Option, #[serde(skip_serializing_if = "Option::is_none")] - required: Option, + pub required: Option, #[serde(skip_serializing_if = "Option::is_none")] - default: Option, + pub default: Option, } impl ModPackArgument { diff --git a/libsysinspect/src/console/mod.rs b/libsysinspect/src/console/mod.rs index 0192a84a..1c22dc77 100644 --- a/libsysinspect/src/console/mod.rs +++ b/libsysinspect/src/console/mod.rs @@ -126,6 +126,19 @@ pub struct ConsoleMasterLogSnapshot { pub errors_path: String, } +/// One argument/option of a module in the repository index. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ConsoleModuleArgument { + pub name: String, + pub description: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub argtype: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub required: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub default: Option, +} + /// One row in the master's module repository index. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct ConsoleModuleRow { @@ -142,6 +155,10 @@ pub struct ConsoleModuleRow { pub author: Option, #[serde(skip_serializing_if = "Option::is_none")] pub manpage: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub args: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub opts: Option>, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] diff --git a/src/ui/dslbrowser.rs b/src/ui/dslbrowser.rs index 021f220b..e65baa56 100644 --- a/src/ui/dslbrowser.rs +++ b/src/ui/dslbrowser.rs @@ -1220,7 +1220,7 @@ fn handle_ctx_edit(code: KeyCode, f: &mut ContextField, idx: usize, total: usize } } -fn wrap_text(text: &str, max_width: usize) -> Vec { +pub(crate) fn wrap_text(text: &str, max_width: usize) -> Vec { if text.is_empty() || max_width < 4 { return vec![]; } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 68f0310a..320c65df 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1776,6 +1776,9 @@ impl SysInspectUX { } return handled; } + if self.repo_manager.info_visible { + return self.repo_manager.handle_info_key(e); + } if self.repo_manager.filter_focus { match e.code { KeyCode::Esc => { @@ -1831,7 +1834,15 @@ impl SysInspectUX { KeyCode::PageDown => { self.repo_manager.cursor = (self.repo_manager.cursor + page).min(max_cursor()); } - KeyCode::Enter => {} // placeholder + KeyCode::Enter => { + if !self.repo_manager.rows.is_empty() { + self.repo_manager.info_visible = true; + self.repo_manager.info_row = self.repo_manager.cursor; + self.repo_manager.info_tab = 0; + self.repo_manager.info_scroll.set(0); + self.status_at_repo_manager(); + } + } KeyCode::Delete => { if !self.repo_manager.rows.is_empty() { self.repo_manager.delete_mode = true; diff --git a/src/ui/repomanager.rs b/src/ui/repomanager.rs index a784070b..879d9861 100644 --- a/src/ui/repomanager.rs +++ b/src/ui/repomanager.rs @@ -1,16 +1,18 @@ use super::{ - palette, + dslbrowser, palette, title::{self, TitleSegment, TitleStyle}, }; -use libsysinspect::console::ConsoleModuleRow; +use libsysinspect::console::{ConsoleModuleArgument, ConsoleModuleRow}; use ratatui::{ layout::{Constraint, Direction, Layout, Position}, prelude::{Buffer, Rect}, style::{Color, Modifier, Style}, - widgets::{Block, BorderType, Borders, Clear, StatefulWidget, Widget}, + text::Line, + widgets::{Block, BorderType, Borders, Clear, Paragraph, StatefulWidget, Tabs, Widget}, }; use ratatui_cheese::input::{Input, InputState, InputStyles}; use ratatui_glamour::color::blend_2d; +use ratatui_glamour::rule::dashed_title; use std::{ cell::Cell, sync::{Arc, Mutex}, @@ -58,6 +60,12 @@ pub struct RepoManager { // Filter pub filter: InputState, pub filter_focus: bool, + + // Module info popup + pub info_visible: bool, + pub info_row: usize, + pub info_tab: u8, + pub info_scroll: Cell, } impl Default for RepoManager { @@ -79,6 +87,10 @@ impl Default for RepoManager { needs_reload: false, filter: InputState::new(), filter_focus: false, + info_visible: false, + info_row: 0, + info_tab: 0, + info_scroll: Cell::new(0), } } } @@ -157,6 +169,8 @@ impl RepoManager { } if self.progress.lock().unwrap().is_some() { self.render_progress(parent, buf); + } else if self.info_visible { + self.render_info(parent, buf); } else if self.staging { self.render_staging(parent, buf); } else { @@ -525,6 +539,291 @@ impl RepoManager { StatefulWidget::render(&inp, Rect::new(input_x, area.y, input_w, 1), buf, &mut is); } + pub fn handle_info_key(&mut self, key: crossterm::event::KeyEvent) -> bool { + if !self.info_visible { + return false; + } + match key.code { + crossterm::event::KeyCode::Esc => { + self.info_visible = false; + } + crossterm::event::KeyCode::Enter => { + self.info_visible = false; + } + crossterm::event::KeyCode::Left => { + self.info_tab = self.info_tab.saturating_sub(1); + self.info_scroll.set(0); + } + crossterm::event::KeyCode::Right => { + self.info_tab = (self.info_tab + 1).min(3); + self.info_scroll.set(0); + } + crossterm::event::KeyCode::Up => { + let s = self.info_scroll.get(); + self.info_scroll.set(s.saturating_sub(1)); + } + crossterm::event::KeyCode::Down => { + let s = self.info_scroll.get(); + self.info_scroll.set(s.saturating_add(1)); + } + crossterm::event::KeyCode::PageUp => { + let s = self.info_scroll.get(); + self.info_scroll.set(s.saturating_sub(10)); + } + crossterm::event::KeyCode::PageDown => { + let s = self.info_scroll.get(); + self.info_scroll.set(s.saturating_add(10)); + } + _ => {} + } + true + } + + fn render_info(&self, parent: Rect, buf: &mut Buffer) { + let row = match self.rows.get(self.info_row) { + Some(r) => r, + None => return, + }; + let w = (parent.width * 80 / 100).max(60).min(parent.width.saturating_sub(2)); + let h = (parent.height * 80 / 100).clamp(12, 24); + let x = parent.x + (parent.width.saturating_sub(w)) / 2; + let y = parent.y + (parent.height.saturating_sub(h)) / 2; + let canvas = Rect { x, y, width: w, height: h }; + + Clear.render(canvas, buf); + let grad = blend_2d(canvas.width as usize, canvas.height as usize, 10.0, &[palette::BG_1, palette::BG_2] as &[Color]); + for ry in 0..canvas.height { + for cx in 0..canvas.width { + let idx = ry as usize * canvas.width as usize + cx as usize; + if let Some(cell) = buf.cell_mut(Position::new(canvas.x + cx, canvas.y + ry)) { + cell.set_bg(grad[idx]); + } + } + } + + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(palette::PROCESSING_GLOW)) + .style(Style::default()); + let inner = block.inner(canvas); + block.render(canvas, buf); + + let title_style = TitleStyle::cyberpunk(palette::PROCESSING_GLOW); + title::overlay_gradient_title( + buf, + canvas, + &title_style, + &[TitleSegment { text: format!(" {} ", row.name), bg: palette::PROCESSING_BASE, fg: palette::FG }], + ); + + if inner.height < 4 { + return; + } + let [tabs_area, body_area, btn_area] = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Min(1), Constraint::Length(1)]) + .split(inner) + .as_ref() + .try_into() + .unwrap(); + + let titles: Vec = ["Description", "Arguments", "Options", "Manual"].iter().map(|t| Line::from(format!(" {t} "))).collect(); + Tabs::new(titles) + .select(self.info_tab as usize) + .divider("│") + .style(Style::default().fg(palette::MUTED)) + .highlight_style(Style::default().fg(palette::BLACK).bg(palette::HIGHLIGHT).add_modifier(Modifier::BOLD)) + .render(tabs_area, buf); + + let section_title = ["Description", "Arguments", "Options", "Manual Page"][self.info_tab as usize]; + let mut yy = body_area.y; + dashed_title( + Rect { x: body_area.x, y: yy, width: body_area.width, height: 1 }, + buf, + &format!(" {section_title} "), + palette::PROCESSING, + palette::PRIMARY, + palette::PROCESSING_DIMMED, + ); + yy += 1; + yy += 1; + + let content_area = Rect { x: body_area.x, y: yy, width: body_area.width.saturating_sub(1), height: body_area.height.saturating_sub(2) }; + let muted = Style::default().fg(palette::MUTED); + + match self.info_tab { + 0 => { + let desc = if row.descr.is_empty() { "Description is not available" } else { &row.descr }; + self.render_info_text(content_area, buf, desc); + } + 1 => { + if let Some(ref args) = row.args + && !args.is_empty() + { + self.render_args_opts(content_area, buf, args, false); + } else { + self.render_placeholder(content_area, buf, "This module has no arguments", &muted); + } + } + 2 => { + if let Some(ref opts) = row.opts + && !opts.is_empty() + { + self.render_args_opts(content_area, buf, opts, true); + } else { + self.render_placeholder(content_area, buf, "This module has no options", &muted); + } + } + 3 => { + if let Some(ref man) = row.manpage + && !man.is_empty() + { + let rendered: Vec = man.split('\n').map(|line| render_markup_spans(line)).collect(); + self.render_info_lines(content_area, buf, &rendered); + } else { + self.render_placeholder(content_area, buf, "Manual page is not available", &muted); + } + } + _ => {} + } + + // Close button + let close_lbl = "[ Close ]"; + let close_w = close_lbl.len() as u16; + let btn_x = btn_area.x + (btn_area.width.saturating_sub(close_w)) / 2; + Paragraph::new(close_lbl) + .style(Style::default().fg(palette::WHITE).bg(palette::PROCESSING_HEAT).add_modifier(Modifier::BOLD)) + .render(Rect::new(btn_x, btn_area.y, close_w, 1), buf); + + // Change default temporarily for shadow computation + Self::draw_shadow(buf, canvas, w, h); + } + + fn render_info_text(&self, area: Rect, buf: &mut Buffer, text: &str) { + let w = (area.width.saturating_sub(3)) as usize; + let lines: Vec = text.split('\n').flat_map(|l| dslbrowser::wrap_text(l, w)).collect(); + let max_off = lines.len().saturating_sub(area.height as usize); + let off = self.info_scroll.get().min(max_off); + let body = Style::default().fg(palette::FG); + for (yy, line) in (area.y..).zip(lines.iter().skip(off).take(area.height as usize)) { + if yy >= area.bottom() { + break; + } + buf.set_string(area.x + 2, yy, line, body); + } + self.draw_scrollbar(buf, area, off, lines.len().max(1), area.height as usize); + } + + fn render_info_lines(&self, area: Rect, buf: &mut Buffer, lines: &[ratatui::text::Line]) { + let max_off = lines.len().saturating_sub(area.height as usize); + let off = self.info_scroll.get().min(max_off); + for (yy, line) in (area.y..).zip(lines.iter().skip(off).take(area.height as usize)) { + if yy >= area.bottom() { + break; + } + let spans = line.spans.clone(); + let mut x = area.x + 2; + for span in spans { + buf.set_span(x, yy, &span, area.width.saturating_sub(x.saturating_sub(area.x))); + x += span.width() as u16; + } + } + self.draw_scrollbar(buf, area, off, lines.len().max(1), area.height as usize); + } + + fn render_placeholder(&self, area: Rect, buf: &mut Buffer, msg: &str, style: &Style) { + let x = area.x + (area.width.saturating_sub(msg.len() as u16)) / 2; + let y = area.y + area.height / 2; + buf.set_string(x, y, msg, *style); + } + + fn render_args_opts(&self, area: Rect, buf: &mut Buffer, items: &[ConsoleModuleArgument], _is_opts: bool) { + let name_max_w = items.iter().map(|a| a.name.len()).max().unwrap_or(8).min(16); + let left_w = name_max_w + 2; + let desc_x = (area.x + 2 + left_w as u16 + 6).max(area.x + 14); + let desc_w = area.right().saturating_sub(desc_x + 1) as usize; + + let key_style = Style::default().fg(palette::WARNING_PEAK); + let req_style = Style::default().fg(palette::ERROR_HEAT); + let opt_style = Style::default().fg(palette::SUCCESS_HEAT); + let def_style = Style::default().fg(palette::WARNING_GLOW); + let desc_style = Style::default().fg(palette::GRAY_1); + + #[derive(Clone)] + struct LineSeg { + text: String, + x: u16, + style: Style, + } + let mut all_rows: Vec> = Vec::new(); + + for item in items { + let is_req = item.required.unwrap_or(false); + let tag = if is_req { "required" } else { "optional" }; + let tag_style = if is_req { req_style } else { opt_style }; + + // Left column + let mut left = vec![ + LineSeg { text: item.name.clone(), x: area.x + 2, style: key_style }, + LineSeg { text: tag.to_string(), x: area.x + 2, style: tag_style }, + ]; + if let Some(ref d) = item.default + && !d.is_empty() + { + left.push(LineSeg { text: format!("default: {d}"), x: area.x + 2, style: def_style }); + } + + // Right column (description, wrapped) + let desc_lines = dslbrowser::wrap_text(&item.description, desc_w); + let right: Vec> = desc_lines.iter().map(|l| vec![LineSeg { text: l.clone(), x: desc_x, style: desc_style }]).collect(); + + // Merge: description starts on same line as name + let rows = left.len().max(right.len()); + for i in 0..rows { + let mut row = Vec::new(); + if i < left.len() { + row.push(left[i].clone()); + } + if i < right.len() { + row.extend(right[i].clone()); + } + all_rows.push(row); + } + // Blank separator + all_rows.push(Vec::new()); + } + + let total = all_rows.len(); + let view_h = area.height as usize; + let max_off = total.saturating_sub(view_h); + let off = self.info_scroll.get().min(max_off); + for (yy, row) in (area.y..).zip(all_rows.iter().skip(off).take(view_h)) { + if yy >= area.bottom() { + break; + } + for seg in row { + buf.set_string(seg.x, yy, &seg.text, seg.style); + } + } + self.draw_scrollbar(buf, area, off, total.max(1), view_h); + } + + fn draw_scrollbar(&self, buf: &mut Buffer, area: Rect, offset: usize, total: usize, view_h: usize) { + let bar_h = ((view_h as f64 / total.max(1) as f64) * view_h as f64).max(1.0) as usize; + let bar_h = bar_h.min(view_h); + let bar_y = ((offset as f64 / total.max(1) as f64) * (view_h - bar_h) as f64) as usize; + for i in 0..view_h { + let sx = area.right().saturating_sub(1); + let sy = area.y + i as u16; + if i >= bar_y && i < bar_y + bar_h { + buf.set_string(sx, sy, "█", Style::default().fg(palette::PROCESSING_HEAT)); + } else { + buf.set_string(sx, sy, "│", Style::default().fg(palette::MUTED)); + } + } + } + fn draw_shadow(buf: &mut Buffer, canvas: Rect, dlg_w: u16, dlg_h: u16) { let buf_area = buf.area(); let x = canvas.x; @@ -558,6 +857,119 @@ impl RepoManager { } } +fn render_markup_spans(input: &str) -> ratatui::text::Line<'static> { + use ratatui::text::Span; + + let mut spans: Vec> = Vec::new(); + let mut current = String::new(); + let mut style = Style::default(); + + fn fg_color(c: char) -> Option { + Some(match c { + 'k' => palette::BG_1, + 'r' => palette::ERROR_PEAK, + 'g' => palette::SUCCESS, + 'y' => palette::WARNING, + 'b' => palette::PROCESSING, + 'm' => palette::HIGHLIGHT, + 'c' => palette::SUCCESS_PEAK, + 'w' => palette::FG, + 'K' => palette::GRAY_1, + 'R' => palette::ERROR_GLOW, + 'G' => palette::SUCCESS_GLOW, + 'Y' => palette::WARNING_PEAK, + 'B' => palette::PROCESSING_GLOW, + 'M' => palette::SECONDARY, + 'C' => palette::SECONDARY, + 'W' => palette::FG, + _ => return None, + }) + } + + fn bg_color(c: char) -> Option { + Some(match c { + 'k' => palette::BG_1, + 'r' => palette::ERROR_BASE, + 'g' => palette::SUCCESS_BASE, + 'y' => palette::WARNING_BASE, + 'b' => palette::PROCESSING_BASE, + 'm' => palette::HIGHLIGHT, + 'c' => palette::SECONDARY, + 'w' => palette::FG, + _ => return None, + }) + } + + fn attrs(chars: &str) -> Modifier { + let mut m = Modifier::empty(); + for c in chars.chars() { + match c { + 'b' => m |= Modifier::BOLD, + 'd' => m |= Modifier::DIM, + 'u' => m |= Modifier::UNDERLINED, + 'i' => m |= Modifier::REVERSED, + 's' => m |= Modifier::CROSSED_OUT, + _ => {} + } + } + m + } + + let mut chars = input.chars().peekable(); + while let Some(ch) = chars.next() { + if ch != '[' { + current.push(ch); + continue; + } + let mut tag = String::new(); + while let Some(&c) = chars.peek() { + chars.next(); + if c == ']' { + break; + } + tag.push(c); + } + if tag == "N" { + if !current.is_empty() { + spans.push(Span::styled(std::mem::take(&mut current), style)); + } + style = Style::default(); + continue; + } + let mut parts = tag.splitn(3, ':'); + let fg = parts.next().unwrap_or(""); + let bg = parts.next().unwrap_or(""); + let at = parts.next().unwrap_or(""); + if !tag.contains(':') { + current.push('['); + current.push_str(&tag); + current.push(']'); + continue; + } + if !current.is_empty() { + spans.push(Span::styled(std::mem::take(&mut current), style)); + } + if let Some(c) = fg.chars().next() + && let Some(col) = fg_color(c) + { + style = style.fg(col); + } + if let Some(c) = bg.chars().next() + && let Some(col) = bg_color(c) + { + style = style.bg(col); + } + style = style.add_modifier(attrs(at)); + } + if !current.is_empty() { + spans.push(Span::styled(current, style)); + } + if spans.is_empty() { + spans.push(Span::raw("")); + } + ratatui::text::Line::from(spans) +} + fn truncate_str(s: &str, max_w: usize) -> String { if s.len() <= max_w { s.to_string() } else { format!("{}…", &s[..max_w.saturating_sub(1)]) } } diff --git a/sysmaster/src/console.rs b/sysmaster/src/console.rs index 353fe877..9885abb6 100644 --- a/sysmaster/src/console.rs +++ b/sysmaster/src/console.rs @@ -13,8 +13,8 @@ use libsysinspect::{ cfg::mmconf::MinionConfig, console::{ ConsoleEnvelope, ConsoleMasterLogSnapshot, ConsoleMinionInfoRow, ConsoleMinionLogRequest, ConsoleMinionLogSnapshot, ConsoleModelRow, - ConsoleModuleRow, ConsoleOnlineMinionRow, ConsolePayload, ConsoleQuery, ConsoleResponse, ConsoleSealed, ConsoleTransportStatusRow, - MinionCommandReply, authorised_console_client, load_master_private_key, + ConsoleModuleArgument, ConsoleModuleRow, ConsoleOnlineMinionRow, ConsolePayload, ConsoleQuery, ConsoleResponse, ConsoleSealed, + ConsoleTransportStatusRow, MinionCommandReply, authorised_console_client, load_master_private_key, }, context::get_context, mdescr::catalog::ModelCatalog, @@ -528,6 +528,28 @@ impl SysMaster { version: attrs.version.clone(), author: attrs.author.clone(), manpage: attrs.manpage.clone(), + args: attrs.args.as_ref().map(|a| { + a.iter() + .map(|aa| ConsoleModuleArgument { + name: aa.name.clone(), + description: aa.description.clone(), + argtype: aa.argtype.clone(), + required: aa.required, + default: aa.default.clone(), + }) + .collect() + }), + opts: attrs.opts.as_ref().map(|o| { + o.iter() + .map(|oo| ConsoleModuleArgument { + name: oo.name.clone(), + description: oo.description.clone(), + argtype: oo.argtype.clone(), + required: oo.required, + default: oo.default.clone(), + }) + .collect() + }), }); } } From dcd9fc735451bb482d40a5571affae8d89335f8a Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Fri, 12 Jun 2026 22:55:33 +0200 Subject: [PATCH 14/25] Update dependencies, refine file picker, add library support --- Cargo.lock | 543 ++++++++++++++++++------------- Cargo.toml | 4 +- libmodpak/src/mpk.rs | 2 +- libsysinspect/src/console/mod.rs | 13 + libsysproto/src/query.rs | 3 + src/clifmt.rs | 1 + src/ui/filepicker.rs | 27 +- src/ui/mod.rs | 117 ++++++- src/ui/repomanager.rs | 422 +++++++++++++++++------- sysmaster/src/console.rs | 27 +- sysmaster/src/master.rs | 6 +- 11 files changed, 795 insertions(+), 370 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cbc51a4e..ed8daf33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8,7 +8,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "bytes", "futures-core", "futures-sink", @@ -29,7 +29,7 @@ dependencies = [ "actix-service", "actix-utils", "actix-web", - "bitflags 2.12.1", + "bitflags 2.13.0", "bytes", "derive_more", "futures-core", @@ -54,7 +54,7 @@ dependencies = [ "actix-tls", "actix-utils", "base64", - "bitflags 2.12.1", + "bitflags 2.13.0", "brotli", "bytes", "bytestring", @@ -397,10 +397,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] -name = "ar_archive_writer" +name = "approx" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ar_archive_writer" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4087686b4b0a3427190bae57a1d9a478dbb2d40c5dc1bd6e2b6d797913bdd348" dependencies = [ "object", ] @@ -735,7 +744,7 @@ dependencies = [ "axum-core", "bytes", "futures-util", - "http 1.4.1", + "http 1.4.2", "http-body", "http-body-util", "itoa", @@ -761,7 +770,7 @@ dependencies = [ "async-trait", "bytes", "futures-util", - "http 1.4.1", + "http 1.4.2", "http-body", "http-body-util", "mime", @@ -799,7 +808,7 @@ version = "0.69.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "cexpr", "clang-sys", "itertools 0.12.1", @@ -836,9 +845,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.12.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" dependencies = [ "serde_core", ] @@ -869,11 +878,11 @@ dependencies = [ [[package]] name = "blake2" -version = "0.10.6" +version = "0.11.0-rc.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +checksum = "061f1a09225e328e1ffbb378d2d49923c0ca5fee19fb5ac1cc9c1e9d52b93690" dependencies = [ - "digest 0.10.7", + "digest 0.11.3", ] [[package]] @@ -886,7 +895,7 @@ dependencies = [ "arrayvec", "cc", "cfg-if", - "constant_time_eq", + "constant_time_eq 0.4.2", "cpufeatures 0.3.0", ] @@ -901,9 +910,9 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", ] @@ -1004,6 +1013,12 @@ dependencies = [ "allocator-api2", ] +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + [[package]] name = "byte-unit" version = "5.2.0" @@ -1181,9 +1196,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.63" +version = "1.2.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" dependencies = [ "find-msvc-tools", "jobserver", @@ -1276,7 +1291,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ - "block-buffer 0.12.0", + "block-buffer 0.12.1", "crypto-common 0.2.2", "inout 0.2.2", ] @@ -1449,6 +1464,12 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +[[package]] +name = "constant_time_eq" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a1ec0cfec728a79a5109075543131387f911cb4d07716436d7ae20533657a96" + [[package]] name = "convert_case" version = "0.10.0" @@ -1675,6 +1696,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -1715,7 +1742,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "crossterm_winapi", "mio", "parking_lot 0.12.5", @@ -1732,7 +1759,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "crossterm_winapi", "derive_more", "document-features", @@ -2075,7 +2102,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.12.0", + "block-buffer 0.12.1", "const-oid 0.10.2", "crypto-common 0.2.2", "ctutils", @@ -2422,6 +2449,12 @@ dependencies = [ "regex", ] +[[package]] +name = "fast-srgb8" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" + [[package]] name = "fastrand" version = "2.4.1" @@ -2477,7 +2510,7 @@ version = "0.1.0" source = "git+https://github.com/tinythings/omnitrace.git?branch=master#c41ec28599d3cb2c528ad6392506cc7f61599f22" dependencies = [ "async-trait", - "bitflags 2.12.1", + "bitflags 2.13.0", "blake3", "globset", "hashbrown 0.16.1", @@ -2748,7 +2781,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27d12c0aed7f1e24276a241aadc4cb8ea9f83000f34bc062b7cc2d51e3b0fabd" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "debugid", "fxhash", "serde", @@ -2842,11 +2875,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] @@ -2885,7 +2920,7 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "ignore", "walkdir", ] @@ -2931,7 +2966,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http 1.4.1", + "http 1.4.2", "indexmap 2.14.0", "slab", "tokio", @@ -2994,6 +3029,11 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "hashlink" @@ -3079,9 +3119,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.1" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -3094,7 +3134,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.4.1", + "http 1.4.2", ] [[package]] @@ -3105,7 +3145,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http 1.4.1", + "http 1.4.2", "http-body", "pin-project-lite", ] @@ -3186,7 +3226,7 @@ dependencies = [ "futures-channel", "futures-core", "h2 0.4.14", - "http 1.4.1", + "http 1.4.2", "http-body", "httparse", "httpdate", @@ -3203,7 +3243,7 @@ version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "http 1.4.1", + "http 1.4.2", "hyper", "hyper-util", "rustls", @@ -3236,7 +3276,7 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http 1.4.1", + "http 1.4.2", "http-body", "hyper", "ipnet", @@ -3479,7 +3519,7 @@ version = "0.1.0" source = "git+https://github.com/tinythings/omnitrace.git?branch=master#c41ec28599d3cb2c528ad6392506cc7f61599f22" dependencies = [ "async-trait", - "bitflags 2.12.1", + "bitflags 2.13.0", "libc", "log", "omnitrace-core", @@ -3489,9 +3529,9 @@ dependencies = [ [[package]] name = "ignore" -version = "0.4.25" +version = "0.4.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +checksum = "b915661dd01db3f05050265b2477bcc6527b3792388e2749b41623cc592be67d" dependencies = [ "crossbeam-deque", "globset", @@ -3821,13 +3861,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.99" +version = "0.3.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -3857,9 +3896,9 @@ dependencies = [ [[package]] name = "junction" -version = "1.4.2" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8cfc352a66ba903c23239ef51e809508b6fc2b0f90e3476ac7a9ff47e863ae95" +checksum = "160f2eade097f30263b548aae5deb12ad349c909baa710fa24b92c9090b2e006" dependencies = [ "scopeguard", "windows-sys 0.61.2", @@ -3885,6 +3924,16 @@ dependencies = [ "cpufeatures 0.2.17", ] +[[package]] +name = "keccak" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", +] + [[package]] name = "kernel" version = "0.1.0" @@ -4090,26 +4139,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "liblzma" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6033b77c21d1f56deeae8014eb9fbe7bdf1765185a6c508b5ca82eeaed7f899" -dependencies = [ - "liblzma-sys", -] - -[[package]] -name = "liblzma-sys" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a60851d15cd8c5346eca4ab8babff585be2ae4bc8097c067291d3ffe2add3b6" -dependencies = [ - "cc", - "libc", - "pkg-config", -] - [[package]] name = "libm" version = "0.2.16" @@ -4441,7 +4470,7 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", ] [[package]] @@ -4496,17 +4525,17 @@ dependencies = [ [[package]] name = "log" -version = "0.4.31" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "113b30b4cd05f7c06868fdb2854f66a7b9fece9a48425351cd532e810d74024f" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] name = "lru" -version = "0.16.4" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9" dependencies = [ - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] @@ -4672,19 +4701,19 @@ checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" [[package]] name = "md-5" -version = "0.10.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" dependencies = [ "cfg-if", - "digest 0.10.7", + "digest 0.11.3", ] [[package]] name = "memchr" -version = "2.8.1" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "memfd" @@ -4726,7 +4755,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "58c38e2799fc0978b65dfff8023ec7843e2330bb462f19198840b34b6582397d" dependencies = [ "byteorder", - "keccak", + "keccak 0.1.6", "rand_core 0.6.4", "zeroize", ] @@ -4810,11 +4839,11 @@ dependencies = [ [[package]] name = "mt19937" -version = "3.2.0" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56bc7ea7924ea1a79a9e817d0483e39295424cf2b1276cf2b968f9a6c9b63b54" +checksum = "25b4ab1a6f4b7820876af86b84adf545d53a52f59c5374856225aad9562d903e" dependencies = [ - "rand_core 0.9.5", + "rand_core 0.10.1", ] [[package]] @@ -4871,7 +4900,7 @@ version = "0.1.0" source = "git+https://github.com/tinythings/omnitrace.git?branch=master#c41ec28599d3cb2c528ad6392506cc7f61599f22" dependencies = [ "async-trait", - "bitflags 2.12.1", + "bitflags 2.13.0", "glob", "libc", "log", @@ -4888,7 +4917,7 @@ version = "0.1.0" source = "git+https://github.com/tinythings/omnitrace.git?branch=master#c41ec28599d3cb2c528ad6392506cc7f61599f22" dependencies = [ "async-trait", - "bitflags 2.12.1", + "bitflags 2.13.0", "libc", "log", "omnitrace-core", @@ -4912,7 +4941,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "cfg-if", "cfg_aliases", "libc", @@ -4925,7 +4954,7 @@ version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "cfg-if", "cfg_aliases", "libc", @@ -5100,7 +5129,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", ] [[package]] @@ -5139,7 +5168,7 @@ version = "0.1.0" source = "git+https://github.com/tinythings/omnitrace.git?branch=master#c41ec28599d3cb2c528ad6392506cc7f61599f22" dependencies = [ "async-trait", - "bitflags 2.12.1", + "bitflags 2.13.0", "libc", "log", "serde", @@ -5166,7 +5195,7 @@ version = "0.10.80" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "cfg-if", "foreign-types", "libc", @@ -5193,9 +5222,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-src" -version = "300.6.0+3.6.2" +version = "300.6.1+3.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8e8cbfd3a4a8c8f089147fd7aaa33cf8c7450c4d09f8f80698a0cf093abeff4" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" dependencies = [ "cc", ] @@ -5245,7 +5274,7 @@ checksum = "46d7ab32b827b5b495bd90fa95a6cb65ccc293555dcc3199ae2937d2d237c8ed" dependencies = [ "async-trait", "bytes", - "http 1.4.1", + "http 1.4.2", "opentelemetry", "reqwest 0.12.28", "tracing", @@ -5258,7 +5287,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d899720fe06916ccba71c01d04ecd77312734e2de3467fd30d9d580c8ce85656" dependencies = [ "futures-core", - "http 1.4.1", + "http 1.4.2", "opentelemetry", "opentelemetry-http", "opentelemetry-proto", @@ -5352,6 +5381,30 @@ dependencies = [ "indexmap 2.14.0", ] +[[package]] +name = "palette" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbf71184cc5ecc2e4e1baccdb21026c20e5fc3dcf63028a086131b3ab00b6e6" +dependencies = [ + "approx", + "fast-srgb8", + "libm", + "palette_derive", +] + +[[package]] +name = "palette_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5030daf005bface118c096f510ffb781fc28f9ab6a32ab224d8631be6851d30" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "pam" version = "0.8.0" @@ -6057,7 +6110,7 @@ version = "0.1.0" source = "git+https://github.com/tinythings/omnitrace.git?branch=master#c41ec28599d3cb2c528ad6392506cc7f61599f22" dependencies = [ "async-trait", - "bitflags 2.12.1", + "bitflags 2.13.0", "libc", "omnitrace-core", "serde", @@ -6071,7 +6124,7 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc5b72d8145275d844d4b5f6d4e1eef00c8cd889edb6035c21675d1bb1f45c9f" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "chrono", "flate2", "hex", @@ -6085,7 +6138,7 @@ version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25485360a54d6861439d60facef26de713b1e126bf015ec8f98239467a2b82f7" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "chrono", "flate2", "procfs-core 0.18.0", @@ -6099,7 +6152,7 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "239df02d8349b06fc07398a3a1697b06418223b1c7725085e801e7c0fc6a12ec" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "chrono", "hex", ] @@ -6110,7 +6163,7 @@ version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6401bf7b6af22f78b563665d15a22e9aef27775b79b149a66ca022468a4e405" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "chrono", "hex", ] @@ -6510,9 +6563,9 @@ dependencies = [ [[package]] name = "ratatui" -version = "0.30.0" +version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +checksum = "1695748e3a735b34968c887ceea5a380b43545903868ae8f5b666593100f6b68" dependencies = [ "instability", "ratatui-core", @@ -6536,19 +6589,21 @@ dependencies = [ [[package]] name = "ratatui-core" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +checksum = "42d3603f354bba8c595fa47860e60142d7372b7210c27044c6a7d0e1a4336b44" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "compact_str", - "hashbrown 0.16.1", + "critical-section", + "hashbrown 0.17.1", "indoc", "itertools 0.14.0", "kasuari", "lru", + "palette", "serde", - "strum 0.27.2", + "strum 0.28.0", "thiserror 2.0.18", "unicode-segmentation", "unicode-truncate", @@ -6557,9 +6612,9 @@ dependencies = [ [[package]] name = "ratatui-crossterm" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +checksum = "2b2867bedcbd6a690ca4f8672a687b730ec07660c79844517b084311b529980c" dependencies = [ "cfg-if", "crossterm 0.28.1", @@ -6571,7 +6626,7 @@ dependencies = [ [[package]] name = "ratatui-glamour" version = "0.1.1" -source = "git+https://github.com/tinythings/ratatui-glamour.git#b06c0ef9435254d95e78e02f91e7b8d15c010bfe" +source = "git+https://github.com/tinythings/ratatui-glamour.git#7990b0c46d0a9b57965f77bfbace56140ec09f0b" dependencies = [ "crossterm 0.28.1", "ratatui", @@ -6580,9 +6635,9 @@ dependencies = [ [[package]] name = "ratatui-macros" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" +checksum = "80fac59720679490d89d200df411faa249be728681adcabed3d047ae72c48f1d" dependencies = [ "ratatui-core", "ratatui-widgets", @@ -6590,9 +6645,9 @@ dependencies = [ [[package]] name = "ratatui-termion" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cade85a8591fbc911e147951422f0d6fd40f4948b271b6216c7dc01838996f8" +checksum = "5c16cc35a9d9114e0b2bb4b22018b96ae7f5fe60e2595dc73e622b4e78624835" dependencies = [ "instability", "ratatui-core", @@ -6601,9 +6656,9 @@ dependencies = [ [[package]] name = "ratatui-termwiz" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" +checksum = "386b8ff8f74ed749509391c56d549761a2fcdb408e1f42e467286bcb7dac8967" dependencies = [ "ratatui-core", "termwiz", @@ -6611,19 +6666,19 @@ dependencies = [ [[package]] name = "ratatui-widgets" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +checksum = "7ef4f17dd7ac3abf5adc2b920a03c61eee4bfe6a88fa5191936895525371d79c" dependencies = [ - "bitflags 2.12.1", - "hashbrown 0.16.1", + "bitflags 2.13.0", + "hashbrown 0.17.1", "indoc", "instability", "itertools 0.14.0", "line-clipping", "ratatui-core", "serde", - "strum 0.27.2", + "strum 0.28.0", "time", "unicode-segmentation", "unicode-width 0.2.2", @@ -6679,7 +6734,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", ] [[package]] @@ -6740,9 +6795,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.3" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ "aho-corasick", "memchr", @@ -6769,9 +6824,9 @@ checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "rend" @@ -6793,7 +6848,7 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "http 1.4.1", + "http 1.4.2", "http-body", "http-body-util", "hyper", @@ -6835,7 +6890,7 @@ dependencies = [ "futures-core", "futures-util", "h2 0.4.14", - "http 1.4.1", + "http 1.4.2", "http-body", "http-body-util", "hyper", @@ -7016,7 +7071,7 @@ version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -7061,9 +7116,9 @@ dependencies = [ [[package]] name = "rust_decimal" -version = "1.42.0" +version = "1.42.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c5108e3d4d903e21aac27f12ba5377b6b34f9f44b325e4894c7924169d06995" +checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a" dependencies = [ "arrayvec", "borsh", @@ -7118,7 +7173,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys 0.4.15", @@ -7131,7 +7186,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys 0.12.1", @@ -7237,7 +7292,7 @@ dependencies = [ [[package]] name = "rustpython" version = "0.5.0" -source = "git+https://github.com/RustPython/RustPython.git#8e35cc9e72ea243df2705acb2cfb1a63fb8fbe0b" +source = "git+https://github.com/RustPython/RustPython.git#be6017cbc8d1fa2b11fda5d6014d706ceac7b1a9" dependencies = [ "dirs", "env_logger", @@ -7256,9 +7311,9 @@ dependencies = [ [[package]] name = "rustpython-codegen" version = "0.5.0" -source = "git+https://github.com/RustPython/RustPython.git#8e35cc9e72ea243df2705acb2cfb1a63fb8fbe0b" +source = "git+https://github.com/RustPython/RustPython.git#be6017cbc8d1fa2b11fda5d6014d706ceac7b1a9" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "indexmap 2.14.0", "itertools 0.14.0", "log", @@ -7279,11 +7334,11 @@ dependencies = [ [[package]] name = "rustpython-common" version = "0.5.0" -source = "git+https://github.com/RustPython/RustPython.git#8e35cc9e72ea243df2705acb2cfb1a63fb8fbe0b" +source = "git+https://github.com/RustPython/RustPython.git#be6017cbc8d1fa2b11fda5d6014d706ceac7b1a9" dependencies = [ "ascii", - "bitflags 2.12.1", - "getrandom 0.3.4", + "bitflags 2.13.0", + "getrandom 0.4.2", "itertools 0.14.0", "libc", "lock_api", @@ -7303,7 +7358,7 @@ dependencies = [ [[package]] name = "rustpython-compiler" version = "0.5.0" -source = "git+https://github.com/RustPython/RustPython.git#8e35cc9e72ea243df2705acb2cfb1a63fb8fbe0b" +source = "git+https://github.com/RustPython/RustPython.git#be6017cbc8d1fa2b11fda5d6014d706ceac7b1a9" dependencies = [ "rustpython-codegen", "rustpython-compiler-core", @@ -7317,9 +7372,9 @@ dependencies = [ [[package]] name = "rustpython-compiler-core" version = "0.5.0" -source = "git+https://github.com/RustPython/RustPython.git#8e35cc9e72ea243df2705acb2cfb1a63fb8fbe0b" +source = "git+https://github.com/RustPython/RustPython.git#be6017cbc8d1fa2b11fda5d6014d706ceac7b1a9" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "bitflagset", "itertools 0.14.0", "lz4_flex", @@ -7332,7 +7387,7 @@ dependencies = [ [[package]] name = "rustpython-derive" version = "0.5.0" -source = "git+https://github.com/RustPython/RustPython.git#8e35cc9e72ea243df2705acb2cfb1a63fb8fbe0b" +source = "git+https://github.com/RustPython/RustPython.git#be6017cbc8d1fa2b11fda5d6014d706ceac7b1a9" dependencies = [ "rustpython-compiler", "rustpython-derive-impl", @@ -7342,7 +7397,7 @@ dependencies = [ [[package]] name = "rustpython-derive-impl" version = "0.5.0" -source = "git+https://github.com/RustPython/RustPython.git#8e35cc9e72ea243df2705acb2cfb1a63fb8fbe0b" +source = "git+https://github.com/RustPython/RustPython.git#be6017cbc8d1fa2b11fda5d6014d706ceac7b1a9" dependencies = [ "itertools 0.14.0", "proc-macro2", @@ -7357,7 +7412,7 @@ dependencies = [ [[package]] name = "rustpython-doc" version = "0.5.0" -source = "git+https://github.com/RustPython/RustPython.git#8e35cc9e72ea243df2705acb2cfb1a63fb8fbe0b" +source = "git+https://github.com/RustPython/RustPython.git#be6017cbc8d1fa2b11fda5d6014d706ceac7b1a9" dependencies = [ "phf 0.13.1", ] @@ -7365,10 +7420,10 @@ dependencies = [ [[package]] name = "rustpython-host_env" version = "0.5.0" -source = "git+https://github.com/RustPython/RustPython.git#8e35cc9e72ea243df2705acb2cfb1a63fb8fbe0b" +source = "git+https://github.com/RustPython/RustPython.git#be6017cbc8d1fa2b11fda5d6014d706ceac7b1a9" dependencies = [ - "bitflags 2.12.1", - "getrandom 0.3.4", + "bitflags 2.13.0", + "getrandom 0.4.2", "junction", "libc", "libffi", @@ -7390,7 +7445,7 @@ dependencies = [ [[package]] name = "rustpython-literal" version = "0.5.0" -source = "git+https://github.com/RustPython/RustPython.git#8e35cc9e72ea243df2705acb2cfb1a63fb8fbe0b" +source = "git+https://github.com/RustPython/RustPython.git#be6017cbc8d1fa2b11fda5d6014d706ceac7b1a9" dependencies = [ "hexf-parse", "icu_properties", @@ -7403,7 +7458,7 @@ dependencies = [ [[package]] name = "rustpython-pylib" version = "0.5.0" -source = "git+https://github.com/RustPython/RustPython.git#8e35cc9e72ea243df2705acb2cfb1a63fb8fbe0b" +source = "git+https://github.com/RustPython/RustPython.git#be6017cbc8d1fa2b11fda5d6014d706ceac7b1a9" dependencies = [ "glob", "rustpython-compiler-core", @@ -7417,7 +7472,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f021ff72cabf5e2cd6d8ec8813d376a8445a228dc610ab56c27bd9054cda70d4" dependencies = [ "aho-corasick", - "bitflags 2.12.1", + "bitflags 2.13.0", "compact_str", "get-size2", "is-macro", @@ -7435,7 +7490,7 @@ version = "0.15.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "01e6ee78bd9671fb5766664b2695fe1f2a92a961f4d9101646c570d8acdb1e0b" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "bstr", "compact_str", "get-size2", @@ -7484,9 +7539,9 @@ dependencies = [ [[package]] name = "rustpython-sre_engine" version = "0.5.0" -source = "git+https://github.com/RustPython/RustPython.git#8e35cc9e72ea243df2705acb2cfb1a63fb8fbe0b" +source = "git+https://github.com/RustPython/RustPython.git#be6017cbc8d1fa2b11fda5d6014d706ceac7b1a9" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "icu_properties", "num_enum", "optional", @@ -7496,7 +7551,7 @@ dependencies = [ [[package]] name = "rustpython-stdlib" version = "0.5.0" -source = "git+https://github.com/RustPython/RustPython.git#8e35cc9e72ea243df2705acb2cfb1a63fb8fbe0b" +source = "git+https://github.com/RustPython/RustPython.git#be6017cbc8d1fa2b11fda5d6014d706ceac7b1a9" dependencies = [ "adler32", "ascii", @@ -7504,25 +7559,23 @@ dependencies = [ "blake2", "bzip2", "chrono", - "constant_time_eq", + "constant_time_eq 0.5.0", "crc32fast", "crossbeam-utils", "csv-core", "der 0.8.0", - "digest 0.10.7", + "digest 0.11.3", "dns-lookup", "dyn-clone", "flate2", "gethostname", "hex", - "hmac 0.12.1", + "hmac 0.13.0", "icu_normalizer", "icu_properties", "indexmap 2.14.0", "itertools 0.14.0", "libc", - "liblzma", - "liblzma-sys", "libz-rs-sys", "mac_address", "malachite-bigint", @@ -7535,12 +7588,12 @@ dependencies = [ "oid-registry 0.8.1", "parking_lot 0.12.5", "paste", - "pbkdf2 0.12.2", + "pbkdf2 0.13.0", "pem-rfc7468 1.0.0", "phf 0.13.1", "pkcs8 0.11.0", "pymath", - "rand_core 0.9.5", + "rand 0.10.1", "rapidhash", "rustls", "rustls-native-certs", @@ -7554,9 +7607,10 @@ dependencies = [ "rustpython-ruff_source_file", "rustpython-ruff_text_size", "rustpython-vm", - "sha-1", - "sha2 0.10.9", + "sha1 0.11.0", + "sha2 0.11.0", "sha3", + "shake", "socket2 0.6.4", "system-configuration", "ucd", @@ -7568,18 +7622,20 @@ dependencies = [ "x509-cert", "x509-parser 0.18.1", "xml", + "xz", + "xz-sys", ] [[package]] name = "rustpython-vm" version = "0.5.0" -source = "git+https://github.com/RustPython/RustPython.git#8e35cc9e72ea243df2705acb2cfb1a63fb8fbe0b" +source = "git+https://github.com/RustPython/RustPython.git#be6017cbc8d1fa2b11fda5d6014d706ceac7b1a9" dependencies = [ "ascii", - "bitflags 2.12.1", + "bitflags 2.13.0", "bstr", "chrono", - "constant_time_eq", + "constant_time_eq 0.5.0", "crossbeam-utils", "exitcode", "glob", @@ -7632,7 +7688,7 @@ dependencies = [ [[package]] name = "rustpython-wtf8" version = "0.5.0" -source = "git+https://github.com/RustPython/RustPython.git#8e35cc9e72ea243df2705acb2cfb1a63fb8fbe0b" +source = "git+https://github.com/RustPython/RustPython.git#be6017cbc8d1fa2b11fda5d6014d706ceac7b1a9" dependencies = [ "ascii", "bstr", @@ -7652,7 +7708,7 @@ version = "18.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a990b25f351b25139ddc7f21ee3f6f56f86d6846b74ac8fad3a719a287cd4a0" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "cfg-if", "clipboard-win", "home", @@ -7792,7 +7848,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -7938,17 +7994,6 @@ dependencies = [ "serde_yaml", ] -[[package]] -name = "sha-1" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5058ada175748e33390e40e872bd0fe59a19f265d0158daa551c5a88a76009c" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest 0.10.7", -] - [[package]] name = "sha1" version = "0.10.6" @@ -8005,12 +8050,24 @@ dependencies = [ [[package]] name = "sha3" -version = "0.10.9" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874" +checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" dependencies = [ - "digest 0.10.7", - "keccak", + "digest 0.11.3", + "keccak 0.2.0", + "sponge-cursor", +] + +[[package]] +name = "shake" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a" +dependencies = [ + "digest 0.11.3", + "keccak 0.2.0", + "sponge-cursor", ] [[package]] @@ -8143,9 +8200,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" dependencies = [ "serde", ] @@ -8182,7 +8239,7 @@ version = "0.1.0" source = "git+https://github.com/tinythings/omnitrace.git?branch=master#c41ec28599d3cb2c528ad6392506cc7f61599f22" dependencies = [ "async-trait", - "bitflags 2.12.1", + "bitflags 2.13.0", "cc", "glob", "libc", @@ -8230,13 +8287,19 @@ dependencies = [ "der 0.8.0", ] +[[package]] +name = "sponge-cursor" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" + [[package]] name = "ssh2" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f84d13b3b8a0d4e91a2629911e951db1bb8671512f5c09d7d4ba34500ba68c8" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "libc", "libssh2-sys", "parking_lot 0.12.5", @@ -8286,15 +8349,15 @@ name = "strum" version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" -dependencies = [ - "strum_macros 0.27.2", -] [[package]] name = "strum" version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", +] [[package]] name = "strum_macros" @@ -8557,7 +8620,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -8578,7 +8641,7 @@ version = "0.27.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc4592f674ce18521c2a81483873a49596655b179f71c5e05d10c1fe66c78745" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "cap-fs-ext", "cap-std", "fd-lock", @@ -8705,7 +8768,7 @@ checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", "base64", - "bitflags 2.12.1", + "bitflags 2.13.0", "fancy-regex", "filedescriptor", "finl_unicode", @@ -9076,7 +9139,7 @@ dependencies = [ "bytes", "flate2", "h2 0.4.14", - "http 1.4.1", + "http 1.4.2", "http-body", "http-body-util", "hyper", @@ -9150,10 +9213,10 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "bytes", "futures-util", - "http 1.4.1", + "http 1.4.2", "http-body", "pin-project-lite", "tower 0.5.3", @@ -9551,9 +9614,9 @@ checksum = "e2eebbbfe4093922c2b6734d7c679ebfebd704a0d7e56dfcb0d05818ce28977d" [[package]] name = "uuid" -version = "1.23.2" +version = "1.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" dependencies = [ "atomic", "getrandom 0.4.2", @@ -9637,7 +9700,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c12f0cd37e6a4513802e3918a9d891ce846d69a7594225b558eafc588c16e7fb" dependencies = [ "anyhow", - "bitflags 2.12.1", + "bitflags 2.13.0", "cap-fs-ext", "cap-rand", "cap-std", @@ -9675,9 +9738,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.122" +version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563" dependencies = [ "cfg-if", "once_cell", @@ -9688,9 +9751,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.72" +version = "0.4.73" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +checksum = "54568702fabf5d4849ce2b90fadfa64168a097eaf4b351ce9df8b687a0086aaf" dependencies = [ "js-sys", "wasm-bindgen", @@ -9698,9 +9761,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.122" +version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -9708,9 +9771,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.122" +version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b" dependencies = [ "bumpalo", "proc-macro2", @@ -9721,9 +9784,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.122" +version = "0.2.123" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92" dependencies = [ "unicode-ident", ] @@ -9813,7 +9876,7 @@ version = "0.236.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9b1e81f3eb254cf7404a82cee6926a4a3ccc5aad80cc3d43608a070c67aa1d7" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "hashbrown 0.15.5", "indexmap 2.14.0", "semver", @@ -9826,7 +9889,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "hashbrown 0.15.5", "indexmap 2.14.0", "semver", @@ -9838,7 +9901,7 @@ version = "0.251.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "437970b35b1a85cfde9c74b2398352d8d653f3bd8e3a3db0c063ea8f5b4b36ff" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "indexmap 2.14.0", "semver", ] @@ -9878,7 +9941,7 @@ dependencies = [ "addr2line", "anyhow", "async-trait", - "bitflags 2.12.1", + "bitflags 2.13.0", "bumpalo", "cc", "cfg-if", @@ -10131,7 +10194,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6c97d4e849494d290e05573298bd372e12be86b2074502dc5e02f4ef7628002" dependencies = [ "anyhow", - "bitflags 2.12.1", + "bitflags 2.13.0", "heck", "indexmap 2.14.0", "wit-parser 0.236.1", @@ -10145,7 +10208,7 @@ checksum = "1eabc75a6afeac11870ee5402268ec0f61fc394728d6dcbe5091a57dc6eb5a57" dependencies = [ "anyhow", "async-trait", - "bitflags 2.12.1", + "bitflags 2.13.0", "bytes", "cap-fs-ext", "cap-net-ext", @@ -10214,9 +10277,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.99" +version = "0.3.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +checksum = "6e0871acf327f283dc6da28a1696cdc64fb355ba9f935d052021fa77f35cce69" dependencies = [ "js-sys", "wasm-bindgen", @@ -10326,18 +10389,18 @@ dependencies = [ [[package]] name = "which" -version = "8.0.2" +version = "8.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81995fafaaaf6ae47a7d0cc83c67caf92aeb7e5331650ae6ff856f7c0c60c459" +checksum = "c789537cf2f7f55be8e6192f92e464174ee55f91af622777f7f1ceb0dbccd03e" dependencies = [ "libc", ] [[package]] name = "wide" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a7714cd0430a663154667c74da5d09325c2387695bee18b3f7f72825aa3693a" +checksum = "dfdfe6a32973f2d1b268b8895845a8a96cac2f0191e72c27cc929036060dbf89" dependencies = [ "bytemuck", "safe_arch", @@ -10357,7 +10420,7 @@ checksum = "7aed7ef247a05956b0a25e7905fdb709ae89e506547af42897e40301b0658d07" dependencies = [ "anyhow", "async-trait", - "bitflags 2.12.1", + "bitflags 2.13.0", "thiserror 2.0.18", "tracing", "wasmtime", @@ -10827,7 +10890,7 @@ version = "0.36.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.0", "windows-sys 0.59.0", ] @@ -10895,7 +10958,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.12.1", + "bitflags 2.13.0", "indexmap 2.14.0", "log", "serde", @@ -11036,7 +11099,7 @@ version = "0.1.0" source = "git+https://github.com/tinythings/omnitrace.git?branch=master#c41ec28599d3cb2c528ad6392506cc7f61599f22" dependencies = [ "async-trait", - "bitflags 2.12.1", + "bitflags 2.13.0", "log", "omnitrace-core", "serde", @@ -11044,11 +11107,41 @@ dependencies = [ "tokio", ] +[[package]] +name = "xz" +version = "0.4.6-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7af8851c229f574c5e4f4e9b6d90ca06c43974a563f7f42de4b88d651bf8f19c" +dependencies = [ + "xz-core", +] + +[[package]] +name = "xz-core" +version = "0.1.0-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef4d12cf9c122b2523cce22d6ab3083f4eb56d1221e5d30ddcc70fbaac553589" +dependencies = [ + "libc", + "memchr", + "windows-sys 0.61.2", +] + +[[package]] +name = "xz-sys" +version = "0.4.6-rc.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "114e3a14e5bb2d46513305741650595041b7e7e1677aa5c9715a09b7a8da08fd" +dependencies = [ + "libc", + "xz-core", +] + [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -11130,18 +11223,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.50" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.50" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 7b1177d0..726156a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ edition = "2024" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -chrono = "0.4.44" +chrono = "=0.4.44" clap = { version = "4.6.1", features = ["unstable-styles"] } colored = "3.1.1" libsysinspect = { path = "./libsysinspect" } @@ -19,7 +19,7 @@ libsetup = { path = "./libsetup" } log = "0.4.29" sysinfo = { version = "0.33.1", features = ["linux-tmpfs"] } tokio = { version = "1.52.3", features = ["full"] } -ratatui = { version = "0.30.0", features = [ +ratatui = { version = "0.30.1", features = [ "all-widgets", "serde", "unstable", diff --git a/libmodpak/src/mpk.rs b/libmodpak/src/mpk.rs index fea8ac9e..e21d541c 100644 --- a/libmodpak/src/mpk.rs +++ b/libmodpak/src/mpk.rs @@ -356,7 +356,7 @@ pub struct ModPakRepoIndex { /// Usually they are meant to be just Python scripts. Possibly .so files could be also /// there, but they have to be unique in naming for each platform/arch and linked /// accordingly. - library: IndexMap, + pub library: IndexMap, /// Statically linked sysminion builds grouped by platform and architecture. #[serde(default)] diff --git a/libsysinspect/src/console/mod.rs b/libsysinspect/src/console/mod.rs index 1c22dc77..18390c24 100644 --- a/libsysinspect/src/console/mod.rs +++ b/libsysinspect/src/console/mod.rs @@ -126,6 +126,14 @@ pub struct ConsoleMasterLogSnapshot { pub errors_path: String, } +/// One library entry in the master's repository index. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ConsoleLibraryRow { + pub name: String, + pub checksum: String, + pub kind: String, +} + /// One argument/option of a module in the repository index. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct ConsoleModuleArgument { @@ -228,6 +236,11 @@ pub enum ConsolePayload { /// One row per indexed module. rows: Vec, }, + /// Library repository index from the master. + MasterLibraryIndex { + /// One row per indexed library file. + rows: Vec, + }, /// Available models discovered by the master. Models { /// One row per discovered model. diff --git a/libsysproto/src/query.rs b/libsysproto/src/query.rs index 7aa067be..eb4d2bf8 100644 --- a/libsysproto/src/query.rs +++ b/libsysproto/src/query.rs @@ -67,6 +67,9 @@ pub mod commands { // Get the module repository index from the master pub const CLUSTER_MODULE_INDEX: &str = "cluster/module/index"; + + // Get the library repository index from the master + pub const CLUSTER_LIBRARY_INDEX: &str = "cluster/library/index"; } /// diff --git a/src/clifmt.rs b/src/clifmt.rs index 92b094cc..b5eb3981 100644 --- a/src/clifmt.rs +++ b/src/clifmt.rs @@ -414,5 +414,6 @@ pub fn render_console_payload(payload: &ConsolePayload) -> String { ConsolePayload::Models { .. } => String::new(), ConsolePayload::MasterLogs { snapshot: _ } => String::new(), ConsolePayload::MasterModuleIndex { .. } => String::new(), + ConsolePayload::MasterLibraryIndex { .. } => String::new(), } } diff --git a/src/ui/filepicker.rs b/src/ui/filepicker.rs index 1d47a32b..bd3f7ce8 100644 --- a/src/ui/filepicker.rs +++ b/src/ui/filepicker.rs @@ -26,6 +26,7 @@ pub enum PickerMode { DirectoryPicker, FilePicker, Any, + LibrarySelector, } #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -238,12 +239,14 @@ impl FilePicker { self.visible = false; } KeyCode::Tab => { - if self.mode == PickerMode::FilePicker && self.entries.len() > self.dirs_end { + if (self.mode == PickerMode::FilePicker || self.mode == PickerMode::Any || self.mode == PickerMode::LibrarySelector) + && self.entries.len() > self.dirs_end + { self.focus = if self.focus == PickerFocus::Dirs { PickerFocus::Files } else { PickerFocus::Dirs }; } } KeyCode::BackTab => { - if self.mode == PickerMode::FilePicker { + if self.mode == PickerMode::FilePicker || self.mode == PickerMode::Any || self.mode == PickerMode::LibrarySelector { self.focus = if self.focus == PickerFocus::Files { PickerFocus::Dirs } else { PickerFocus::Files }; } } @@ -315,7 +318,11 @@ impl FilePicker { PickerFocus::Files => self.dirs_end + self.file_cursor, }; if let Some(entry) = self.entries.get(idx) { - let selectable = if self.mode == PickerMode::Any { !entry.is_parent } else { !entry.is_parent && !entry.is_dir }; + let selectable = if self.mode == PickerMode::Any || self.mode == PickerMode::LibrarySelector { + !entry.is_parent + } else { + !entry.is_parent && !entry.is_dir + }; if selectable { self.selected = Some(entry.path.clone()); self.filter_input = InputState::new(); @@ -351,14 +358,14 @@ impl FilePicker { } let dlg_w = (parent.width * 3 / 4).clamp(60, 80); - let dlg_h = (parent.height * 3 / 4).clamp(14, 24); + let dlg_h = parent.height.saturating_sub(4); let x = parent.x + (parent.width.saturating_sub(dlg_w)) / 2; let y = parent.y + (parent.height.saturating_sub(dlg_h)) / 2; let canvas = Rect { x, y, width: dlg_w, height: dlg_h }; Clear.render(canvas, buf); - let grad = blend_2d(canvas.width as usize, canvas.height as usize, 10.0, &[palette::GRAY_0, palette::BG_2] as &[Color]); + let grad = blend_2d(canvas.width as usize, canvas.height as usize, 10.0, &[palette::BG_1, palette::BG_0] as &[Color]); for row in 0..canvas.height { for col in 0..canvas.width { let idx = row as usize * canvas.width as usize + col as usize; @@ -380,6 +387,7 @@ impl FilePicker { PickerMode::DirectoryPicker => " Directory Selector ", PickerMode::FilePicker => " File Selector ", PickerMode::Any => " Module Selector ", + PickerMode::LibrarySelector => " Library Selector ", }; let title_style = TitleStyle::cyberpunk(palette::PROCESSING_GLOW); title::overlay_gradient_title( @@ -413,7 +421,8 @@ impl FilePicker { let filter_line = filter_active as u16; row_y += 1; - let sections: u16 = if self.mode == PickerMode::FilePicker { 2 } else { 1 }; + let sections: u16 = + if self.mode == PickerMode::FilePicker || self.mode == PickerMode::Any || self.mode == PickerMode::LibrarySelector { 2 } else { 1 }; let available = inner.height.saturating_sub(1).saturating_sub(row_y.saturating_sub(inner.y)).saturating_sub(filter_line); let dir_rows = if sections == 2 { available / 2 } else { available }; @@ -435,8 +444,10 @@ impl FilePicker { self.render_section(dir_area, buf, &dir_list, self.dir_cursor, self.focus == PickerFocus::Dirs, &self.dir_scroll); row_y = dir_area.y + dir_area.height; - // ── Files section (FilePicker only) ── - if self.mode == PickerMode::FilePicker && row_y + 1 < inner.y + inner.height { + // ── Files section ── + if (self.mode == PickerMode::FilePicker || self.mode == PickerMode::Any || self.mode == PickerMode::LibrarySelector) + && row_y + 1 < inner.y + inner.height + { dashed_title( Rect { x: inner.x, y: row_y, width: inner.width, height: 1 }, buf, diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 320c65df..ea800aa0 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -17,8 +17,9 @@ use libsysinspect::{ use libsysproto::query::{ SCHEME_COMMAND, commands::{ - CLUSTER_MASTER_LOGS, CLUSTER_MINION_HOPSTART, CLUSTER_MINION_INFO, CLUSTER_MINION_LOGS, CLUSTER_MINION_RECONNECT, CLUSTER_MINION_SHUTDOWN, - CLUSTER_MODELS, CLUSTER_MODULE_INDEX, CLUSTER_ONLINE_MINIONS, CLUSTER_RECONNECT, CLUSTER_SHUTDOWN, CLUSTER_TRAITS_UPDATE, + CLUSTER_LIBRARY_INDEX, CLUSTER_MASTER_LOGS, CLUSTER_MINION_HOPSTART, CLUSTER_MINION_INFO, CLUSTER_MINION_LOGS, CLUSTER_MINION_RECONNECT, + CLUSTER_MINION_SHUTDOWN, CLUSTER_MODELS, CLUSTER_MODULE_INDEX, CLUSTER_ONLINE_MINIONS, CLUSTER_RECONNECT, CLUSTER_SHUTDOWN, + CLUSTER_TRAITS_UPDATE, }, }; use ratatui::{ @@ -643,7 +644,11 @@ impl SysInspectUX { if self.repo_manager.visible && let Some(path) = self.file_picker.selected.take() { - self.process_module_add(&path); + match self.repo_manager.active_tab { + 0 => self.process_module_add(&path), + 1 => self.process_library_add(&path), + _ => {} + } } // Detect progress bar completion for bulk add if self.repo_manager.visible { @@ -1236,6 +1241,7 @@ impl SysInspectUX { || self.dsl_browser.visible || self.setup_wizard.visible || self.file_picker.visible + || self.repo_manager.visible } fn sync_main_focus_for_overlays(&mut self) { @@ -1814,7 +1820,10 @@ impl SysInspectUX { } return true; } - let max_cursor = || self.repo_filtered_count().saturating_sub(1); + let total_count = if self.repo_manager.active_tab == 1 { self.repo_filtered_lib_count() } else { self.repo_filtered_count() }; + let max_cursor = total_count.saturating_sub(1); + let cursor_ref: &mut usize = + if self.repo_manager.active_tab == 1 { &mut self.repo_manager.lib_cursor } else { &mut self.repo_manager.cursor }; let page = 10usize; match e.code { KeyCode::Esc => { @@ -1822,24 +1831,50 @@ impl SysInspectUX { self.repo_manager.visible = false; self.status_at_cycles(); } + KeyCode::Left => { + self.repo_manager.active_tab = self.repo_manager.active_tab.saturating_sub(1); + self.repo_manager.cursor = 0; + self.repo_manager.lib_cursor = 0; + if self.repo_manager.active_tab == 1 { + let _ = self.load_library_index(); + } + } + KeyCode::Right => { + self.repo_manager.active_tab = (self.repo_manager.active_tab + 1).min(2); + self.repo_manager.cursor = 0; + self.repo_manager.lib_cursor = 0; + if self.repo_manager.active_tab == 1 { + let _ = self.load_library_index(); + } + } KeyCode::Up => { - self.repo_manager.cursor = self.repo_manager.cursor.saturating_sub(1); + *cursor_ref = cursor_ref.saturating_sub(1); } KeyCode::Down => { - self.repo_manager.cursor = (self.repo_manager.cursor + 1).min(max_cursor()); + *cursor_ref = (*cursor_ref + 1).min(max_cursor); } KeyCode::PageUp => { - self.repo_manager.cursor = self.repo_manager.cursor.saturating_sub(page); + *cursor_ref = cursor_ref.saturating_sub(page); } KeyCode::PageDown => { - self.repo_manager.cursor = (self.repo_manager.cursor + page).min(max_cursor()); + *cursor_ref = (*cursor_ref + page).min(max_cursor); } KeyCode::Enter => { - if !self.repo_manager.rows.is_empty() { + if self.repo_manager.active_tab == 1 { + if !self.repo_manager.lib_rows.is_empty() { + self.repo_manager.info_visible = true; + self.repo_manager.info_row = self.repo_manager.lib_cursor; + self.repo_manager.info_tab = 0; + self.repo_manager.info_scroll.set(0); + self.repo_manager.info_active_tab = 1; + self.status_at_repo_manager(); + } + } else if !self.repo_manager.rows.is_empty() { self.repo_manager.info_visible = true; self.repo_manager.info_row = self.repo_manager.cursor; self.repo_manager.info_tab = 0; self.repo_manager.info_scroll.set(0); + self.repo_manager.info_active_tab = 0; self.status_at_repo_manager(); } } @@ -1864,7 +1899,8 @@ impl SysInspectUX { } } KeyCode::Insert | KeyCode::Char('i') if !e.modifiers.contains(KeyModifiers::CONTROL) => { - self.file_picker.open(&std::env::current_dir().unwrap_or_default(), filepicker::PickerMode::Any); + let mode = if self.repo_manager.active_tab == 1 { filepicker::PickerMode::LibrarySelector } else { filepicker::PickerMode::Any }; + self.file_picker.open(&std::env::current_dir().unwrap_or_default(), mode); } KeyCode::Char('l') if !e.modifiers.contains(KeyModifiers::CONTROL) => { self.error_alert_visible = true; @@ -1886,6 +1922,27 @@ impl SysInspectUX { self.repo_manager.rows.iter().filter(|r| f.is_empty() || r.name.to_lowercase().contains(&f) || r.descr.to_lowercase().contains(&f)).count() } + fn repo_filtered_lib_count(&self) -> usize { + let f = self.repo_manager.filter.value().to_lowercase(); + self.repo_manager.lib_rows.iter().filter(|r| f.is_empty() || r.name.to_lowercase().contains(&f) || r.kind.to_lowercase().contains(&f)).count() + } + + fn load_library_index(&mut self) -> Result<(), String> { + let resp = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current() + .block_on(async { call_master_console(&self.cfg, &format!("{SCHEME_COMMAND}{CLUSTER_LIBRARY_INDEX}"), "*", None, None, None).await }) + }) + .map_err(|e| format!("Failed to get library index: {e}"))?; + match resp.payload { + ConsolePayload::MasterLibraryIndex { rows } => { + self.repo_manager.lib_rows = rows; + self.repo_manager.lib_cursor = 0; + Ok(()) + } + _ => Err("Unexpected console payload for library index".to_string()), + } + } + fn process_module_add(&mut self, path: &std::path::Path) { if path.is_dir() { let mut staged = Self::scan_dir_for_modules(path); @@ -2029,6 +2086,46 @@ impl SysInspectUX { } } + fn process_library_add(&mut self, path: &std::path::Path) { + let repo_root = self.cfg.fileserver_root().join("repo"); + let mut repo = match SysInspectModPak::new(repo_root) { + Ok(r) => r, + Err(e) => { + self.error_alert_visible = true; + self.error_alert_message = format!("Cannot open repository: {e}"); + return; + } + }; + if path.is_dir() { + if let Err(e) = repo.add_library(path.to_path_buf()) { + self.error_alert_visible = true; + self.error_alert_message = format!("Cannot add library: {e}"); + } else { + self.load_library_index().ok(); + } + } else { + // Single file: wrap in temp dir, use add_library + let tmp = std::env::temp_dir().join("sysinspect_lib_add"); + let _ = std::fs::create_dir_all(&tmp); + let dest = tmp.join(path.file_name().unwrap_or_default()); + match std::fs::copy(path, &dest) { + Ok(_) => { + if let Err(e) = repo.add_library(tmp.clone()) { + self.error_alert_visible = true; + self.error_alert_message = format!("Cannot add library file: {e}"); + } else { + self.load_library_index().ok(); + } + let _ = std::fs::remove_dir_all(&tmp); + } + Err(e) => { + self.error_alert_visible = true; + self.error_alert_message = format!("Cannot copy library file: {e}"); + } + } + } + } + fn read_spec_version_descr(spec: &std::path::Path) -> (Option, String) { match std::fs::read_to_string(spec) { Ok(yaml) => match serde_yaml::from_str::(&yaml) { diff --git a/src/ui/repomanager.rs b/src/ui/repomanager.rs index 879d9861..629815db 100644 --- a/src/ui/repomanager.rs +++ b/src/ui/repomanager.rs @@ -66,6 +66,13 @@ pub struct RepoManager { pub info_row: usize, pub info_tab: u8, pub info_scroll: Cell, + pub info_active_tab: u8, + + // Tabs + pub active_tab: u8, + pub lib_rows: Vec, + pub lib_cursor: usize, + pub lib_scroll: Cell, } impl Default for RepoManager { @@ -91,6 +98,11 @@ impl Default for RepoManager { info_row: 0, info_tab: 0, info_scroll: Cell::new(0), + info_active_tab: 0, + active_tab: 0, + lib_rows: Vec::new(), + lib_cursor: 0, + lib_scroll: Cell::new(0), } } } @@ -167,14 +179,15 @@ impl RepoManager { if !self.visible { return; } + self.render_main(parent, buf); if self.progress.lock().unwrap().is_some() { self.render_progress(parent, buf); - } else if self.info_visible { + } + if self.info_visible { self.render_info(parent, buf); - } else if self.staging { + } + if self.staging { self.render_staging(parent, buf); - } else { - self.render_main(parent, buf); } } @@ -187,7 +200,7 @@ impl RepoManager { Clear.render(canvas, buf); - let grad = blend_2d(canvas.width as usize, canvas.height as usize, 10.0, &[palette::GRAY_0, palette::BG_2] as &[Color]); + let grad = blend_2d(canvas.width as usize, canvas.height as usize, 10.0, &[palette::BG_3, palette::BG_1] as &[Color]); for row in 0..canvas.height { for col in 0..canvas.width { let idx = row as usize * canvas.width as usize + col as usize; @@ -205,112 +218,43 @@ impl RepoManager { let inner = block.inner(canvas); block.render(canvas, buf); + let tab_names = ["Modules", "Libraries", "Profiles"]; + let section_name = tab_names[self.active_tab as usize]; let title_style = TitleStyle::cyberpunk(palette::PROCESSING_GLOW); title::overlay_gradient_title( buf, canvas, &title_style, - &[TitleSegment { text: " Module and Library Manager ".into(), bg: palette::PROCESSING_BASE, fg: palette::FG }], + &[ + TitleSegment { text: " Artefacts ".into(), bg: palette::PROCESSING_BASE, fg: palette::FG }, + TitleSegment { text: format!(" {section_name} "), bg: palette::PROCESSING_HEAT, fg: palette::FG }, + ], ); - if inner.height >= 4 { - // Filter row - let [filter_area, list_area] = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(1), Constraint::Min(0)]) - .split(inner) - .as_ref() - .try_into() - .unwrap(); - - Self::render_filter_row(filter_area, buf, self.filter_focus, &self.filter); - - if !self.rows.is_empty() { - let inner = list_area; - let name_w: u16 = 28; - let ver_w: u16 = 6; - let desc_w = inner.width.saturating_sub(name_w + ver_w + 2); - - // Build filtered list - let f = self.filter.value().to_lowercase(); - let filtered: Vec<(usize, &ConsoleModuleRow)> = self - .rows - .iter() - .enumerate() - .filter(|(_, r)| f.is_empty() || r.name.to_lowercase().contains(&f) || r.descr.to_lowercase().contains(&f)) - .collect(); - - let view_h = inner.height as usize; - let total = filtered.len(); - let max_scroll = total.saturating_sub(view_h); - let mut s = self.scroll.get(); - let cursor = self.cursor.min(total.saturating_sub(1)); - if cursor < s { - s = cursor; - } - if cursor >= s + view_h { - s = cursor.saturating_sub(view_h.saturating_sub(1)); - } - s = s.min(max_scroll); - self.scroll.set(s); - - if total == 0 { - let msg = "(no matches)"; - let x = inner.x + (inner.width.saturating_sub(msg.len() as u16)) / 2; - let y = inner.y + inner.height / 2; - buf.set_string(x, y, msg, Style::default().fg(palette::MUTED)); - } else { - let hl = Style::default().fg(palette::BLACK).bg(palette::HIGHLIGHT); - for i in 0..view_h.min(total.saturating_sub(s)) { - let fi = s + i; - let (_orig_idx, row) = filtered[fi]; - let ry = inner.y + i as u16; - let selected = !self.filter_focus && fi == cursor; - let row_style = if selected { hl } else { Style::default().fg(palette::FG) }; - - if selected { - for cx in 0..inner.width { - if let Some(cell) = buf.cell_mut(Position::new(inner.x + cx, ry)) { - cell.set_bg(palette::HIGHLIGHT); - } - } - } - - let name = truncate_str(&row.name, name_w as usize); - buf.set_string(inner.x + 1, ry, format!(" {name}"), row_style); - - let ver = row.version.as_deref().unwrap_or("—"); - let ver_style = if selected { row_style } else { Style::default().fg(palette::HIGHLIGHT) }; - buf.set_string(inner.x + 1 + name_w + 1, ry, truncate_str(ver, ver_w as usize), ver_style); - - let desc_x = inner.x + 1 + name_w + 1 + ver_w + 1; - let max_desc = desc_w.saturating_sub(1) as usize; - let desc_style = if selected { row_style } else { Style::default().fg(palette::GRAY_1) }; - buf.set_string(desc_x, ry, truncate_str(&row.descr, max_desc), desc_style); - } - - if total > view_h { - let bar_h = ((view_h as f64 / total as f64) * view_h as f64).max(1.0) as usize; - let bar_y = ((s as f64 / total as f64) * (view_h - bar_h) as f64) as usize; - for i in 0..view_h { - let sx = inner.right().saturating_sub(1); - let sy = inner.y + i as u16; - if i >= bar_y && i < bar_y + bar_h { - buf.set_string(sx, sy, "█", Style::default().fg(palette::PROCESSING_HEAT)); - } else { - buf.set_string(sx, sy, "│", Style::default().fg(palette::MUTED)); - } - } - } - } - } - } else if inner.height >= 3 { - let msg = "(no modules found)"; - let x = inner.x + (inner.width.saturating_sub(msg.len() as u16)) / 2; - let y = inner.y + inner.height / 2; - buf.set_string(x, y, msg, Style::default().fg(palette::MUTED)); + if inner.height < 4 { + return; } + let [tabs_area, body] = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Min(0)]) + .split(inner) + .as_ref() + .try_into() + .unwrap(); + let titles: Vec = tab_names.iter().map(|t| Line::from(format!(" {t} "))).collect(); + Tabs::new(titles) + .select(self.active_tab as usize) + .divider("\u{E0B1}") + .style(Style::default().fg(palette::MUTED)) + .highlight_style(Style::default().fg(palette::GRAY_2).add_modifier(Modifier::BOLD)) + .render(tabs_area, buf); + match self.active_tab { + 0 => self.render_modules(body, buf), + 1 => self.render_libraries(body, buf), + 2 => self.render_profiles_placeholder(body, buf), + _ => {} + } Self::draw_shadow(buf, canvas, dlg_w, dlg_h); } @@ -325,7 +269,7 @@ impl RepoManager { Clear.render(canvas, buf); - let grad = blend_2d(canvas.width as usize, canvas.height as usize, 10.0, &[palette::GRAY_0, palette::BG_2] as &[Color]); + let grad = blend_2d(canvas.width as usize, canvas.height as usize, 10.0, &[palette::BG_3, palette::BG_1] as &[Color]); for row in 0..canvas.height { for col in 0..canvas.width { let idx = row as usize * canvas.width as usize + col as usize; @@ -476,14 +420,6 @@ impl RepoManager { let inner = block.inner(canvas); block.render(canvas, buf); - let title_style = TitleStyle::cyberpunk(palette::PROCESSING_GLOW); - title::overlay_gradient_title( - buf, - canvas, - &title_style, - &[TitleSegment { text: " Adding Modules ".into(), bg: palette::PROCESSING_BASE, fg: palette::FG }], - ); - let bar_y = inner.y + 1; let bar_w = inner.width.saturating_sub(2); let filled = (bar_w as usize * done).checked_div(total).map(|v| v as u16).unwrap_or(0); @@ -510,12 +446,190 @@ impl RepoManager { Self::draw_shadow(buf, canvas, dlg_w, dlg_h); } + fn render_modules(&self, inner: Rect, buf: &mut Buffer) { + if inner.height < 2 { + return; + } + let [filter_area, list_area] = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Min(0)]) + .split(inner) + .as_ref() + .try_into() + .unwrap(); + Self::render_filter_row(filter_area, buf, self.filter_focus, &self.filter); + let name_w: u16 = 28; + let ver_w: u16 = 6; + if self.rows.is_empty() { + let msg = "(no modules found)"; + let x = list_area.x + (list_area.width.saturating_sub(msg.len() as u16)) / 2; + let y = list_area.y + list_area.height / 2; + buf.set_string(x, y, msg, Style::default().fg(palette::MUTED)); + return; + } + let flt = self.filter.value().to_lowercase(); + let filtered: Vec<(usize, &ConsoleModuleRow)> = self + .rows + .iter() + .enumerate() + .filter(|(_, r)| flt.is_empty() || r.name.to_lowercase().contains(&flt) || r.descr.to_lowercase().contains(&flt)) + .collect(); + let view_h = list_area.height as usize; + let total = filtered.len(); + let max_scroll = total.saturating_sub(view_h); + let mut s = self.scroll.get(); + let cursor = self.cursor.min(total.saturating_sub(1)); + if cursor < s { + s = cursor; + } + if cursor >= s + view_h { + s = cursor.saturating_sub(view_h.saturating_sub(1)); + } + s = s.min(max_scroll); + self.scroll.set(s); + if total == 0 { + let msg = "(no matches)"; + let x = list_area.x + (list_area.width.saturating_sub(msg.len() as u16)) / 2; + let y = list_area.y + list_area.height / 2; + buf.set_string(x, y, msg, Style::default().fg(palette::MUTED)); + return; + } + let hl = Style::default().fg(palette::BLACK).bg(palette::HIGHLIGHT); + for i in 0..view_h.min(total.saturating_sub(s)) { + let fi = s + i; + let (_oi, row) = filtered[fi]; + let ry = list_area.y + i as u16; + let sel = !self.filter_focus && fi == cursor; + let row_style = if sel { hl } else { Style::default().fg(palette::FG) }; + if sel { + for cx in 0..list_area.width { + if let Some(cell) = buf.cell_mut(Position::new(list_area.x + cx, ry)) { + cell.set_bg(palette::HIGHLIGHT); + } + } + } + buf.set_string(list_area.x + 1, ry, format!(" {}", truncate_str(&row.name, name_w as usize)), row_style); + let ver_style = if sel { row_style } else { Style::default().fg(palette::HIGHLIGHT) }; + buf.set_string(list_area.x + 1 + name_w + 1, ry, truncate_str(row.version.as_deref().unwrap_or("—"), ver_w as usize), ver_style); + let desc_style = if sel { row_style } else { Style::default().fg(palette::GRAY_1) }; + let desc_x = list_area.x + 1 + name_w + 1 + ver_w + 1; + let max_desc = (list_area.width.saturating_sub(name_w + ver_w + 3)) as usize; + buf.set_string(desc_x, ry, truncate_str(&row.descr, max_desc), desc_style); + } + if total > view_h { + let bh = ((view_h as f64 / total as f64) * view_h as f64).max(1.0) as usize; + let by = ((s as f64 / total as f64) * (view_h - bh) as f64) as usize; + for i in 0..view_h { + let sx = list_area.right().saturating_sub(1); + let sy = list_area.y + i as u16; + if i >= by && i < by + bh { + buf.set_string(sx, sy, "█", Style::default().fg(palette::PROCESSING_HEAT)); + } else { + buf.set_string(sx, sy, "│", Style::default().fg(palette::MUTED)); + } + } + } + } + + fn render_libraries(&self, inner: Rect, buf: &mut Buffer) { + if inner.height < 2 { + return; + } + let [filter_area, list_area] = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(1), Constraint::Min(0)]) + .split(inner) + .as_ref() + .try_into() + .unwrap(); + Self::render_filter_row(filter_area, buf, self.filter_focus, &self.filter); + if self.lib_rows.is_empty() { + let msg = "(no libraries found)"; + let x = list_area.x + (list_area.width.saturating_sub(msg.len() as u16)) / 2; + let y = list_area.y + list_area.height / 2; + buf.set_string(x, y, msg, Style::default().fg(palette::MUTED)); + return; + } + let flt = self.filter.value().to_lowercase(); + let filtered: Vec<(usize, &libsysinspect::console::ConsoleLibraryRow)> = self + .lib_rows + .iter() + .enumerate() + .filter(|(_, r)| flt.is_empty() || r.name.to_lowercase().contains(&flt) || r.kind.to_lowercase().contains(&flt)) + .collect(); + let view_h = list_area.height as usize; + let total = filtered.len(); + let max_scroll = total.saturating_sub(view_h); + let mut s = self.lib_scroll.get(); + let cursor = self.lib_cursor.min(total.saturating_sub(1)); + if cursor < s { + s = cursor; + } + if cursor >= s + view_h { + s = cursor.saturating_sub(view_h.saturating_sub(1)); + } + s = s.min(max_scroll); + self.lib_scroll.set(s); + if total == 0 { + let msg = "(no matches)"; + let x = list_area.x + (list_area.width.saturating_sub(msg.len() as u16)) / 2; + let y = list_area.y + list_area.height / 2; + buf.set_string(x, y, msg, Style::default().fg(palette::MUTED)); + return; + } + let hl = Style::default().fg(palette::BLACK).bg(palette::HIGHLIGHT); + let kind_w: u16 = 8; + let name_w = list_area.width.saturating_sub(kind_w + 38); + let sum_w = 30u16; + for i in 0..view_h.min(total.saturating_sub(s)) { + let fi = s + i; + let (_oi, row) = filtered[fi]; + let ry = list_area.y + i as u16; + let sel = !self.filter_focus && fi == cursor; + let row_style = if sel { hl } else { Style::default().fg(palette::FG) }; + if sel { + for cx in 0..list_area.width { + if let Some(cell) = buf.cell_mut(Position::new(list_area.x + cx, ry)) { + cell.set_bg(palette::HIGHLIGHT); + } + } + } + let kind_style = if sel { row_style } else { Style::default().fg(palette::PROCESSING) }; + buf.set_string(list_area.x + 1, ry, format!(" {}", truncate_str(&row.kind, kind_w as usize)), kind_style); + let name_style = if sel { row_style } else { Style::default().fg(palette::FG) }; + buf.set_string(list_area.x + 1 + kind_w + 1, ry, truncate_str(&row.name, name_w as usize), name_style); + let sum_style = if sel { row_style } else { Style::default().fg(palette::GRAY_1) }; + let sum_x = list_area.x + 1 + kind_w + 1 + name_w + 1; + buf.set_string(sum_x, ry, truncate_str(&row.checksum, sum_w as usize), sum_style); + } + if total > view_h { + let bh = ((view_h as f64 / total as f64) * view_h as f64).max(1.0) as usize; + let by = ((s as f64 / total as f64) * (view_h - bh) as f64) as usize; + for i in 0..view_h { + let sx = list_area.right().saturating_sub(1); + let sy = list_area.y + i as u16; + if i >= by && i < by + bh { + buf.set_string(sx, sy, "█", Style::default().fg(palette::PROCESSING_HEAT)); + } else { + buf.set_string(sx, sy, "│", Style::default().fg(palette::MUTED)); + } + } + } + } + + fn render_profiles_placeholder(&self, inner: Rect, buf: &mut Buffer) { + let msg = "Profiles management is not implemented yet"; + let x = inner.x + (inner.width.saturating_sub(msg.len() as u16)) / 2; + let y = inner.y + inner.height / 2; + buf.set_string(x, y, msg, Style::default().fg(palette::MUTED)); + } + fn render_filter_row(area: Rect, buf: &mut Buffer, focused: bool, filter_state: &InputState) { let label_style = if focused { Style::default().fg(palette::ACCENT) } else { Style::default().fg(palette::MUTED) }; - buf.set_string(area.x, area.y, "filter: ", label_style); + buf.set_string(area.x + 2, area.y, "filter: ", label_style); - let input_x = area.x + 8u16; - let input_w = area.width.saturating_sub(8); + let input_x = area.x + 10u16; + let input_w = area.width.saturating_sub(10); if input_w == 0 { return; } @@ -550,11 +664,11 @@ impl RepoManager { crossterm::event::KeyCode::Enter => { self.info_visible = false; } - crossterm::event::KeyCode::Left => { + crossterm::event::KeyCode::Left if self.info_active_tab == 0 => { self.info_tab = self.info_tab.saturating_sub(1); self.info_scroll.set(0); } - crossterm::event::KeyCode::Right => { + crossterm::event::KeyCode::Right if self.info_active_tab == 0 => { self.info_tab = (self.info_tab + 1).min(3); self.info_scroll.set(0); } @@ -580,6 +694,14 @@ impl RepoManager { } fn render_info(&self, parent: Rect, buf: &mut Buffer) { + match self.info_active_tab { + 0 => self.render_module_info(parent, buf), + 1 => self.render_library_info(parent, buf), + _ => {} + } + } + + fn render_module_info(&self, parent: Rect, buf: &mut Buffer) { let row = match self.rows.get(self.info_row) { Some(r) => r, None => return, @@ -631,9 +753,9 @@ impl RepoManager { let titles: Vec = ["Description", "Arguments", "Options", "Manual"].iter().map(|t| Line::from(format!(" {t} "))).collect(); Tabs::new(titles) .select(self.info_tab as usize) - .divider("│") + .divider("\u{E0B1}") .style(Style::default().fg(palette::MUTED)) - .highlight_style(Style::default().fg(palette::BLACK).bg(palette::HIGHLIGHT).add_modifier(Modifier::BOLD)) + .highlight_style(Style::default().fg(palette::GRAY_2).add_modifier(Modifier::BOLD)) .render(tabs_area, buf); let section_title = ["Description", "Arguments", "Options", "Manual Page"][self.info_tab as usize]; @@ -700,6 +822,70 @@ impl RepoManager { Self::draw_shadow(buf, canvas, w, h); } + fn render_library_info(&self, parent: Rect, buf: &mut Buffer) { + let lib = match self.lib_rows.get(self.info_row) { + Some(r) => r, + None => return, + }; + let w = (parent.width * 60 / 100).max(50).min(parent.width.saturating_sub(2)); + let h: u16 = 8; + let x = parent.x + (parent.width.saturating_sub(w)) / 2; + let y = parent.y + (parent.height.saturating_sub(h)) / 2; + let canvas = Rect { x, y, width: w, height: h }; + + Clear.render(canvas, buf); + let grad = blend_2d(canvas.width as usize, canvas.height as usize, 10.0, &[palette::BG_1, palette::BG_2] as &[Color]); + for ry in 0..canvas.height { + for cx in 0..canvas.width { + let idx = ry as usize * canvas.width as usize + cx as usize; + if let Some(cell) = buf.cell_mut(Position::new(canvas.x + cx, canvas.y + ry)) { + cell.set_bg(grad[idx]); + } + } + } + + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(palette::PROCESSING_GLOW)) + .style(Style::default()); + let inner = block.inner(canvas); + block.render(canvas, buf); + + let title_style = TitleStyle::cyberpunk(palette::PROCESSING_GLOW); + title::overlay_gradient_title( + buf, + canvas, + &title_style, + &[TitleSegment { text: format!(" {} ", lib.name), bg: palette::PROCESSING_BASE, fg: palette::FG }], + ); + + let key_style = Style::default().fg(palette::PROCESSING).add_modifier(Modifier::BOLD); + let val_style = Style::default().fg(palette::FG); + + let lines = [("Name: ", &lib.name), ("Type: ", &lib.kind), ("Sha256: ", &lib.checksum)]; + for (i, (label, value)) in lines.iter().enumerate() { + let ry = inner.y + 1 + i as u16; + buf.set_string(inner.x + 3, ry, label, key_style); + buf.set_string( + inner.x + 3 + label.len() as u16, + ry, + truncate_str(value, (inner.width as usize).saturating_sub(3 + label.len() + 2)), + val_style, + ); + } + + let close_lbl = "[ Close ]"; + let close_w = close_lbl.len() as u16; + let btn_x = inner.x + (inner.width.saturating_sub(close_w)) / 2; + let btn_y = inner.y + 5; + Paragraph::new(close_lbl) + .style(Style::default().fg(palette::WHITE).bg(palette::PROCESSING_HEAT).add_modifier(Modifier::BOLD)) + .render(Rect::new(btn_x, btn_y, close_w, 1), buf); + + Self::draw_shadow(buf, canvas, w, h); + } + fn render_info_text(&self, area: Rect, buf: &mut Buffer, text: &str) { let w = (area.width.saturating_sub(3)) as usize; let lines: Vec = text.split('\n').flat_map(|l| dslbrowser::wrap_text(l, w)).collect(); diff --git a/sysmaster/src/console.rs b/sysmaster/src/console.rs index 9885abb6..2d4482a1 100644 --- a/sysmaster/src/console.rs +++ b/sysmaster/src/console.rs @@ -12,9 +12,9 @@ use libmodpak::{SysInspectModPak, compare_versions, mpk::ModPakRepoIndex}; use libsysinspect::{ cfg::mmconf::MinionConfig, console::{ - ConsoleEnvelope, ConsoleMasterLogSnapshot, ConsoleMinionInfoRow, ConsoleMinionLogRequest, ConsoleMinionLogSnapshot, ConsoleModelRow, - ConsoleModuleArgument, ConsoleModuleRow, ConsoleOnlineMinionRow, ConsolePayload, ConsoleQuery, ConsoleResponse, ConsoleSealed, - ConsoleTransportStatusRow, MinionCommandReply, authorised_console_client, load_master_private_key, + ConsoleEnvelope, ConsoleLibraryRow, ConsoleMasterLogSnapshot, ConsoleMinionInfoRow, ConsoleMinionLogRequest, ConsoleMinionLogSnapshot, + ConsoleModelRow, ConsoleModuleArgument, ConsoleModuleRow, ConsoleOnlineMinionRow, ConsolePayload, ConsoleQuery, ConsoleResponse, + ConsoleSealed, ConsoleTransportStatusRow, MinionCommandReply, authorised_console_client, load_master_private_key, }, context::get_context, mdescr::catalog::ModelCatalog, @@ -557,6 +557,20 @@ impl SysMaster { Ok(rows) } + async fn library_index_data(&mut self) -> Result, SysinspectError> { + let idx_path = self.cfg.fileserver_root().join("repo").join("mod.index"); + if !idx_path.exists() { + return Ok(Vec::new()); + } + let yaml = std::fs::read_to_string(&idx_path).map_err(|e| SysinspectError::ConfigError(format!("Cannot read module index: {e}")))?; + let idx = ModPakRepoIndex::from_yaml(&yaml)?; + let mut rows = Vec::new(); + for (name, file) in idx.library.iter() { + rows.push(ConsoleLibraryRow { name: name.clone(), checksum: file.checksum().to_string(), kind: file.kind().to_string() }); + } + Ok(rows) + } + async fn upsert_cmdb_console_response(&mut self, mid: &str, context: &str) -> Result { if mid.trim().is_empty() { return Ok(ConsoleResponse::err("CMDB update requires a minion id")); @@ -769,6 +783,13 @@ impl SysMaster { }; } + if query.model.eq(&format!("{SCHEME_COMMAND}{CLUSTER_LIBRARY_INDEX}")) { + return match master.lock().await.library_index_data().await { + Ok(rows) => ConsoleResponse::ok(ConsolePayload::MasterLibraryIndex { rows }), + Err(err) => ConsoleResponse::err(format!("Unable to get library index: {err}")), + }; + } + if query.model.eq(&format!("{SCHEME_COMMAND}{CLUSTER_TRANSPORT_STATUS}")) { return match TransportStatusConsoleRequest::from_context(&query.context) { Ok(request) => match master.lock().await.transport_status_data(&request, &query.query, &query.traits, &query.mid).await { diff --git a/sysmaster/src/master.rs b/sysmaster/src/master.rs index c166a819..da09872d 100644 --- a/sysmaster/src/master.rs +++ b/sysmaster/src/master.rs @@ -39,9 +39,9 @@ use libsysproto::{ query::{ SCHEME_COMMAND, commands::{ - CLUSTER_CMDB_UPSERT, CLUSTER_HOPSTART, CLUSTER_MASTER_LOGS, CLUSTER_MINION_HOPSTART, CLUSTER_MINION_INFO, CLUSTER_MINION_LOGS, - CLUSTER_MINION_RECONNECT, CLUSTER_MINION_SHUTDOWN, CLUSTER_MODELS, CLUSTER_MODULE_INDEX, CLUSTER_ONLINE_MINIONS, CLUSTER_PROFILE, - CLUSTER_REMOVE_MINION, CLUSTER_ROTATE, CLUSTER_TRAITS_UPDATE, CLUSTER_TRANSPORT_STATUS, + CLUSTER_CMDB_UPSERT, CLUSTER_HOPSTART, CLUSTER_LIBRARY_INDEX, CLUSTER_MASTER_LOGS, CLUSTER_MINION_HOPSTART, CLUSTER_MINION_INFO, + CLUSTER_MINION_LOGS, CLUSTER_MINION_RECONNECT, CLUSTER_MINION_SHUTDOWN, CLUSTER_MODELS, CLUSTER_MODULE_INDEX, CLUSTER_ONLINE_MINIONS, + CLUSTER_PROFILE, CLUSTER_REMOVE_MINION, CLUSTER_ROTATE, CLUSTER_TRAITS_UPDATE, CLUSTER_TRANSPORT_STATUS, }, }, replay::{ReplayIdentity, replay_identity_for_master_command_cycle, replay_identity_from_minion_message}, From e16f77dd0fe13cd1a7ba0627ce5f443945bd90a5 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Fri, 12 Jun 2026 23:13:09 +0200 Subject: [PATCH 15/25] Add current path, change selector style in file picker --- src/ui/filepicker.rs | 123 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 97 insertions(+), 26 deletions(-) diff --git a/src/ui/filepicker.rs b/src/ui/filepicker.rs index bd3f7ce8..058d8f96 100644 --- a/src/ui/filepicker.rs +++ b/src/ui/filepicker.rs @@ -19,7 +19,7 @@ use std::{ os::unix::fs::{MetadataExt, PermissionsExt}, path::PathBuf, }; -use unicode_width::UnicodeWidthStr; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum PickerMode { @@ -81,6 +81,72 @@ impl Default for FilePicker { } } +fn fold_path_fish_style(path: &str, max_width: usize) -> String { + if path.is_empty() { + return ".".into(); + } + let display_path = if let Ok(home) = std::env::var("HOME") + && path.starts_with(home.as_str()) + && (path.len() == home.len() || path.as_bytes()[home.len()] == b'/') + { + let tail = &path[home.len()..]; + let trimmed = tail.trim_start_matches('/'); + if trimmed.is_empty() { "~".into() } else { format!("~/{}", trimmed) } + } else { + path.to_string() + }; + let components: Vec<&str> = display_path.split('/').collect(); + if components.len() <= 2 { + let w = UnicodeWidthStr::width(display_path.as_str()); + if w <= max_width { + return display_path; + } + return prefix_ellipsis(&display_path, max_width); + } + let last = *components.last().unwrap(); + let intermediates = &components[1..components.len() - 1]; + let mut folded = String::new(); + let starts_with_slash = display_path.starts_with('/'); + let starts_with_tilde = display_path.starts_with("~/"); + if starts_with_tilde { + folded.push_str("~/"); + } else if starts_with_slash { + folded.push('/'); + } + for comp in intermediates { + if let Some(ch) = comp.chars().next() { + folded.push(ch); + folded.push('/'); + } + } + folded.push_str(last); + let w = UnicodeWidthStr::width(folded.as_str()); + if w <= max_width { + return folded; + } + prefix_ellipsis(&folded, max_width) +} + +fn prefix_ellipsis(text: &str, max_width: usize) -> String { + let ellipsis_w = UnicodeWidthStr::width("…"); + if max_width <= ellipsis_w { + return "…".into(); + } + let available = max_width - ellipsis_w; + let chars: Vec = text.chars().collect(); + let mut right_width = 0usize; + let mut start_idx = chars.len(); + for (i, ch) in chars.iter().enumerate().rev() { + right_width += UnicodeWidthChar::width(*ch).unwrap_or(0); + if right_width >= available { + start_idx = i; + break; + } + } + let tail: String = chars[start_idx..].iter().collect(); + format!("…{}", tail) +} + impl FilePicker { pub fn open(&mut self, path: &std::path::Path, mode: PickerMode) { self.visible = true; @@ -197,7 +263,7 @@ impl FilePicker { self.focus = PickerFocus::Dirs; self.refresh_entries(); } - KeyCode::Tab | KeyCode::BackTab => { + KeyCode::Down | KeyCode::Tab | KeyCode::BackTab => { self.filter_focus = false; self.refresh_entries(); } @@ -251,6 +317,9 @@ impl FilePicker { } } KeyCode::Up => match self.focus { + PickerFocus::Dirs if self.dir_cursor == 0 && self.entries.first().is_some_and(|e| e.is_parent) => { + self.filter_focus = true; + } PickerFocus::Dirs => { self.dir_cursor = self.dir_cursor.saturating_sub(1); } @@ -390,12 +459,18 @@ impl FilePicker { PickerMode::LibrarySelector => " Library Selector ", }; let title_style = TitleStyle::cyberpunk(palette::PROCESSING_GLOW); - title::overlay_gradient_title( - buf, - canvas, - &title_style, - &[TitleSegment { text: title_text.into(), bg: palette::PROCESSING_BASE, fg: palette::FG }], - ); + + let title_area_w = canvas.width.saturating_sub(2); + let mode_w = UnicodeWidthStr::width(title_text) as u16; + let path_avail = title_area_w.saturating_sub(3 + mode_w) as usize; + let path_str = self.current_path.to_string_lossy().to_string(); + let folded = if path_avail > 0 { fold_path_fish_style(&path_str, path_avail) } else { String::new() }; + + let mut segments = vec![TitleSegment { text: title_text.into(), bg: palette::PROCESSING_BASE, fg: palette::FG }]; + if !folded.is_empty() { + segments.push(TitleSegment { text: folded, bg: palette::PROCESSING_HEAT, fg: palette::FG }); + } + title::overlay_gradient_title(buf, canvas, &title_style, &segments); if inner.height < 4 { return; @@ -441,7 +516,7 @@ impl FilePicker { let dir_end = (row_y + dir_rows).min(inner.y + inner.height); let dir_area = Rect { x: inner.x + 1, y: row_y, width: inner.width.saturating_sub(1), height: dir_rows.min(dir_end.saturating_sub(row_y)) }; - self.render_section(dir_area, buf, &dir_list, self.dir_cursor, self.focus == PickerFocus::Dirs, &self.dir_scroll); + self.render_section(dir_area, buf, &dir_list, self.dir_cursor, !self.filter_focus && self.focus == PickerFocus::Dirs, &self.dir_scroll); row_y = dir_area.y + dir_area.height; // ── Files section ── @@ -467,7 +542,14 @@ impl FilePicker { height: (available - dir_rows).saturating_sub(1).min(file_end.saturating_sub(row_y)), }; - self.render_section(file_area, buf, &file_list, self.file_cursor, self.focus == PickerFocus::Files, &self.file_scroll); + self.render_section( + file_area, + buf, + &file_list, + self.file_cursor, + !self.filter_focus && self.focus == PickerFocus::Files, + &self.file_scroll, + ); } // MS-DOS shadow @@ -522,8 +604,8 @@ impl FilePicker { let visible = entries.iter().skip(s).take(view_h); let muted = Style::default().fg(palette::MUTED); - let hl_style = Style::default().fg(palette::BLACK).bg(palette::HIGHLIGHT); - let muted_hl = Style::default().fg(palette::BG_0).bg(palette::HIGHLIGHT); + let hl_style = Style::default().fg(palette::HIGHLIGHT).add_modifier(Modifier::BOLD); + let muted_hl = Style::default().fg(palette::HIGHLIGHT).add_modifier(Modifier::BOLD); // Calculate field widths for alignment (includes icon + spaces) let longest_name = entries @@ -545,8 +627,9 @@ impl FilePicker { let is_selected = abs_idx == cursor && active; let row_style = if is_selected { hl_style } else { Style::default().fg(palette::FG) }; - let icon = if entry.is_parent { "↑ " } else { entry.icon }; - let line = format!(" {icon} {}", entry.name); + let icon_str = if entry.is_parent { "↑ " } else { entry.icon }; + let prefix = if is_selected { " ✨ " } else { " " }; + let line = format!("{prefix}{icon_str} {}", entry.name); if !entry.is_parent { let (mode, user, group, mtime) = Self::meta_info_or_unknown(&entry.path); @@ -563,18 +646,6 @@ impl FilePicker { } else { buf.set_string(area.x + 1, ry, &line, row_style); } - - // Re-paint with highlight if selected - if is_selected { - if !entry.is_parent { - let name_end = (area.x + 3 + longest_name as u16).saturating_sub(1); - let max_name_w = name_end.saturating_sub(area.x + 1); - let name_trimmed = truncate_to_width(&line, max_name_w); - buf.set_string(area.x + 1, ry, &name_trimmed, row_style); - } else { - buf.set_string(area.x + 1, ry, &line, row_style); - } - } } // Scrollbar From 116b0cf0a28a7ce53ec2454a93b50daaa16da612 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Fri, 12 Jun 2026 23:38:21 +0200 Subject: [PATCH 16/25] Implement profiles manager --- src/ui/mod.rs | 322 +++++++++++++++- src/ui/profiles.rs | 834 ++++++++++++++++++++++++++++++++++++++++++ src/ui/repomanager.rs | 76 +++- src/ui/statusbar.rs | 27 ++ 4 files changed, 1234 insertions(+), 25 deletions(-) create mode 100644 src/ui/profiles.rs diff --git a/src/ui/mod.rs b/src/ui/mod.rs index ea800aa0..2917d26e 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -18,7 +18,7 @@ use libsysproto::query::{ SCHEME_COMMAND, commands::{ CLUSTER_LIBRARY_INDEX, CLUSTER_MASTER_LOGS, CLUSTER_MINION_HOPSTART, CLUSTER_MINION_INFO, CLUSTER_MINION_LOGS, CLUSTER_MINION_RECONNECT, - CLUSTER_MINION_SHUTDOWN, CLUSTER_MODELS, CLUSTER_MODULE_INDEX, CLUSTER_ONLINE_MINIONS, CLUSTER_RECONNECT, CLUSTER_SHUTDOWN, + CLUSTER_MINION_SHUTDOWN, CLUSTER_MODELS, CLUSTER_MODULE_INDEX, CLUSTER_ONLINE_MINIONS, CLUSTER_PROFILE, CLUSTER_RECONNECT, CLUSTER_SHUTDOWN, CLUSTER_TRAITS_UPDATE, }, }; @@ -47,6 +47,7 @@ mod filepicker; mod macts; mod online; mod palette; +mod profiles; mod rawlogs; mod repomanager; mod setup; @@ -1242,6 +1243,9 @@ impl SysInspectUX { || self.setup_wizard.visible || self.file_picker.visible || self.repo_manager.visible + || self.repo_manager.profiles.detail_visible + || self.repo_manager.profiles.create_visible + || self.repo_manager.profiles.delete_visible } fn sync_main_focus_for_overlays(&mut self) { @@ -1763,10 +1767,20 @@ impl SysInspectUX { let checked: Vec<_> = self.repo_manager.staged.iter().filter(|m| m.checked).cloned().collect(); if checked.is_empty() { self.error_alert_visible = true; - self.error_alert_message = "No modules selected".to_string(); + self.error_alert_message = "No items selected".to_string(); } else { self.repo_manager.exit_staging(); - self.bulk_add_modules(checked); + match self.repo_manager.staging_mode { + repomanager::StagingMode::ProfileModuleAdd => { + self.bulk_add_profile_matches(checked, false); + } + repomanager::StagingMode::ProfileLibraryAdd => { + self.bulk_add_profile_matches(checked, true); + } + _ => { + self.bulk_add_modules(checked); + } + } } } if self.repo_manager.bulk_delete_triggered { @@ -1780,6 +1794,12 @@ impl SysInspectUX { self.bulk_delete_modules(&checked); } } + if !self.repo_manager.staging + && matches!(self.repo_manager.staging_mode, repomanager::StagingMode::ProfileModuleAdd | repomanager::StagingMode::ProfileLibraryAdd) + { + self.repo_manager.profiles.detail_visible = true; + self.status_at_profiles(); + } return handled; } if self.repo_manager.info_visible { @@ -1820,10 +1840,107 @@ impl SysInspectUX { } return true; } - let total_count = if self.repo_manager.active_tab == 1 { self.repo_filtered_lib_count() } else { self.repo_filtered_count() }; + // Profile-specific overlays (tab 2) + if self.repo_manager.active_tab == 2 { + if self.repo_manager.profiles.delete_visible { + let handled = self.repo_manager.profiles.handle_delete_key(e.code); + if !handled && e.code == KeyCode::Enter { + if self.repo_manager.profiles.delete_focus == profiles::ProfDeleteFocus::YesBtn { + let name = self.repo_manager.profiles.delete_name.clone(); + let _ = self.do_profile_delete(&name); + self.repo_manager.profiles.delete_visible = false; + self.status_at_profiles(); + } else { + self.repo_manager.profiles.delete_visible = false; + self.status_at_profiles(); + } + } + return true; + } + if self.repo_manager.profiles.create_visible { + let handled = self.repo_manager.profiles.handle_create_key(e.code); + if !handled && e.code == KeyCode::Enter { + match self.repo_manager.profiles.create_focus { + profiles::ProfCreateFocus::CreateBtn => { + let name = self.repo_manager.profiles.create_input.value().to_string(); + if !name.is_empty() { + let _ = self.do_profile_create(&name); + } + self.repo_manager.profiles.create_visible = false; + self.status_at_profiles(); + } + profiles::ProfCreateFocus::CancelBtn => { + self.repo_manager.profiles.create_visible = false; + self.status_at_profiles(); + } + _ => {} + } + } + return true; + } + if self.repo_manager.profiles.detail_visible { + let handled = self.repo_manager.profiles.handle_detail_key(e.code); + if !handled { + match e.code { + KeyCode::Enter => match self.repo_manager.profiles.detail_focus { + profiles::ProfDetailFocus::AddModuleBtn => { + self.repo_manager.enter_profile_module_staging(); + } + profiles::ProfDetailFocus::AddLibraryBtn => { + self.repo_manager.enter_profile_library_staging(); + } + profiles::ProfDetailFocus::CloseBtn => { + self.repo_manager.profiles.detail_visible = false; + self.status_at_profiles(); + } + _ => {} + }, + KeyCode::Char('d') | KeyCode::Delete => { + let name = self.repo_manager.profiles.detail_name.clone(); + match self.repo_manager.profiles.detail_focus { + profiles::ProfDetailFocus::Modules => { + if let Some(m) = self.repo_manager.profiles.detail_selected_module() { + let sel = m.selector.clone(); + let _ = self.do_profile_remove_match(&name, &sel, false); + if let Ok((mods, libs)) = self.load_profile_detail(&name) { + self.repo_manager.profiles.enter_detail(name, mods, libs); + } + } + } + profiles::ProfDetailFocus::Libraries => { + if let Some(l) = self.repo_manager.profiles.detail_selected_library() { + let sel = l.selector.clone(); + let _ = self.do_profile_remove_match(&name, &sel, true); + if let Ok((mods, libs)) = self.load_profile_detail(&name) { + self.repo_manager.profiles.enter_detail(name, mods, libs); + } + } + } + _ => {} + } + self.status_at_profiles(); + } + _ => {} + } + } + return true; + } + } + let total_count = if self.repo_manager.active_tab == 2 { + self.repo_manager.profiles.filtered_count(self.repo_manager.filter.value()) + } else if self.repo_manager.active_tab == 1 { + self.repo_filtered_lib_count() + } else { + self.repo_filtered_count() + }; let max_cursor = total_count.saturating_sub(1); - let cursor_ref: &mut usize = - if self.repo_manager.active_tab == 1 { &mut self.repo_manager.lib_cursor } else { &mut self.repo_manager.cursor }; + let cursor_ref: &mut usize = if self.repo_manager.active_tab == 2 { + &mut self.repo_manager.profiles.cursor + } else if self.repo_manager.active_tab == 1 { + &mut self.repo_manager.lib_cursor + } else { + &mut self.repo_manager.cursor + }; let page = 10usize; match e.code { KeyCode::Esc => { @@ -1835,32 +1952,75 @@ impl SysInspectUX { self.repo_manager.active_tab = self.repo_manager.active_tab.saturating_sub(1); self.repo_manager.cursor = 0; self.repo_manager.lib_cursor = 0; + self.repo_manager.profiles.cursor = 0; if self.repo_manager.active_tab == 1 { let _ = self.load_library_index(); } + if self.repo_manager.active_tab == 2 { + let _ = self.load_profile_list(); + } } KeyCode::Right => { self.repo_manager.active_tab = (self.repo_manager.active_tab + 1).min(2); self.repo_manager.cursor = 0; self.repo_manager.lib_cursor = 0; + self.repo_manager.profiles.cursor = 0; if self.repo_manager.active_tab == 1 { let _ = self.load_library_index(); } + if self.repo_manager.active_tab == 2 { + let _ = self.load_profile_list(); + } } KeyCode::Up => { - *cursor_ref = cursor_ref.saturating_sub(1); + if self.repo_manager.active_tab == 2 { + let fv = self.repo_manager.filter.value().to_string(); + self.repo_manager.profiles.handle_list_key(e.code, &mut self.repo_manager.filter_focus, &fv); + } else { + *cursor_ref = cursor_ref.saturating_sub(1); + } } KeyCode::Down => { - *cursor_ref = (*cursor_ref + 1).min(max_cursor); + if self.repo_manager.active_tab == 2 { + let fv = self.repo_manager.filter.value().to_string(); + self.repo_manager.profiles.handle_list_key(e.code, &mut self.repo_manager.filter_focus, &fv); + } else { + *cursor_ref = (*cursor_ref + 1).min(max_cursor); + } } KeyCode::PageUp => { - *cursor_ref = cursor_ref.saturating_sub(page); + if self.repo_manager.active_tab == 2 { + let fv = self.repo_manager.filter.value().to_string(); + self.repo_manager.profiles.handle_list_key(e.code, &mut self.repo_manager.filter_focus, &fv); + } else { + *cursor_ref = cursor_ref.saturating_sub(page); + } } KeyCode::PageDown => { - *cursor_ref = (*cursor_ref + page).min(max_cursor); + if self.repo_manager.active_tab == 2 { + let fv = self.repo_manager.filter.value().to_string(); + self.repo_manager.profiles.handle_list_key(e.code, &mut self.repo_manager.filter_focus, &fv); + } else { + *cursor_ref = (*cursor_ref + page).min(max_cursor); + } } KeyCode::Enter => { - if self.repo_manager.active_tab == 1 { + if self.repo_manager.active_tab == 2 { + let name = match self.repo_manager.profiles.selected_profile_name() { + Some(n) => n.to_string(), + None => return true, + }; + match self.load_profile_detail(&name) { + Ok((modules, libraries)) => { + self.repo_manager.profiles.enter_detail(name, modules, libraries); + self.status_at_profiles(); + } + Err(e) => { + self.error_alert_visible = true; + self.error_alert_message = e; + } + } + } else if self.repo_manager.active_tab == 1 { if !self.repo_manager.lib_rows.is_empty() { self.repo_manager.info_visible = true; self.repo_manager.info_row = self.repo_manager.lib_cursor; @@ -1879,7 +2039,12 @@ impl SysInspectUX { } } KeyCode::Delete => { - if !self.repo_manager.rows.is_empty() { + if self.repo_manager.active_tab == 2 { + if let Some(name) = self.repo_manager.profiles.selected_profile_name() { + self.repo_manager.profiles.open_delete(name.to_string()); + self.status_at_profiles(); + } + } else if !self.repo_manager.rows.is_empty() { self.repo_manager.delete_mode = true; self.repo_manager.staged = self .repo_manager @@ -1899,12 +2064,22 @@ impl SysInspectUX { } } KeyCode::Insert | KeyCode::Char('i') if !e.modifiers.contains(KeyModifiers::CONTROL) => { - let mode = if self.repo_manager.active_tab == 1 { filepicker::PickerMode::LibrarySelector } else { filepicker::PickerMode::Any }; - self.file_picker.open(&std::env::current_dir().unwrap_or_default(), mode); + if self.repo_manager.active_tab == 2 { + self.repo_manager.profiles.open_create(); + self.status_at_profiles(); + } else { + let mode = if self.repo_manager.active_tab == 1 { filepicker::PickerMode::LibrarySelector } else { filepicker::PickerMode::Any }; + self.file_picker.open(&std::env::current_dir().unwrap_or_default(), mode); + } } KeyCode::Char('l') if !e.modifiers.contains(KeyModifiers::CONTROL) => { - self.error_alert_visible = true; - self.error_alert_message = "Not implemented yet".to_string(); + if self.repo_manager.active_tab == 2 { + self.repo_manager.profiles.open_create(); + self.status_at_profiles(); + } else { + self.error_alert_visible = true; + self.error_alert_message = "Not implemented yet".to_string(); + } } KeyCode::Tab => { self.repo_manager.filter_focus = true; @@ -1927,6 +2102,121 @@ impl SysInspectUX { self.repo_manager.lib_rows.iter().filter(|r| f.is_empty() || r.name.to_lowercase().contains(&f) || r.kind.to_lowercase().contains(&f)).count() } + fn call_profile_rpc(&self, context: &str) -> Result { + let ctx = context.to_string(); + let resp = tokio::task::block_in_place(|| { + tokio::runtime::Handle::current() + .block_on(async { call_master_console(&self.cfg, &format!("{SCHEME_COMMAND}{CLUSTER_PROFILE}"), "*", None, None, Some(&ctx)).await }) + }) + .map_err(|e| format!("Profile RPC failed: {e}"))?; + Ok(resp.payload) + } + + fn load_profile_list(&mut self) -> Result<(), String> { + let payload = self.call_profile_rpc(r#"{"op":"list"}"#)?; + match payload { + ConsolePayload::StringList { items } => { + self.repo_manager.profiles.profiles = items; + self.repo_manager.profiles.cursor = 0; + Ok(()) + } + _ => Err("Unexpected console payload for profile list".to_string()), + } + } + + fn load_profile_detail(&mut self, name: &str) -> Result<(Vec, Vec), String> { + let ctx_mods = serde_json::json!({"op": "list", "name": name, "library": false}).to_string(); + let payload_mods = self.call_profile_rpc(&ctx_mods)?; + let module_selectors = match payload_mods { + ConsolePayload::StringList { items } => items, + _ => return Err("Unexpected payload for profile module selectors".to_string()), + }; + + let ctx_libs = serde_json::json!({"op": "list", "name": name, "library": true}).to_string(); + let payload_libs = self.call_profile_rpc(&ctx_libs)?; + let library_selectors = match payload_libs { + ConsolePayload::StringList { items } => items, + _ => return Err("Unexpected payload for profile library selectors".to_string()), + }; + + let resolved_modules: Vec = module_selectors + .iter() + .flat_map(|sel| { + self.repo_manager + .rows + .iter() + .filter(|r| glob::Pattern::new(sel).is_ok_and(|p| p.matches(&r.name))) + .map(|r| profiles::ResolvedModule { + name: r.name.clone(), + version: r.version.clone().unwrap_or_default(), + descr: r.descr.clone(), + selector: sel.clone(), + }) + .collect::>() + }) + .collect(); + + let resolved_libraries: Vec = library_selectors + .iter() + .flat_map(|sel| { + self.repo_manager + .lib_rows + .iter() + .filter(|r| glob::Pattern::new(sel).is_ok_and(|p| p.matches(&r.name))) + .map(|r| profiles::ResolvedLibrary { + name: r.name.clone(), + kind: r.kind.clone(), + checksum: r.checksum.clone(), + selector: sel.clone(), + }) + .collect::>() + }) + .collect(); + + Ok((resolved_modules, resolved_libraries)) + } + + fn do_profile_create(&mut self, name: &str) -> Result<(), String> { + let ctx = serde_json::json!({"op": "new", "name": name}).to_string(); + self.call_profile_rpc(&ctx)?; + self.load_profile_list()?; + Ok(()) + } + + fn do_profile_delete(&mut self, name: &str) -> Result<(), String> { + let ctx = serde_json::json!({"op": "delete", "name": name}).to_string(); + self.call_profile_rpc(&ctx)?; + self.load_profile_list()?; + Ok(()) + } + + fn do_profile_add_matches(&mut self, name: &str, matches: Vec, library: bool) -> Result<(), String> { + let ctx = serde_json::json!({"op": "add", "name": name, "matches": matches, "library": library}).to_string(); + self.call_profile_rpc(&ctx)?; + Ok(()) + } + + fn do_profile_remove_match(&mut self, name: &str, selector: &str, library: bool) -> Result<(), String> { + let ctx = serde_json::json!({"op": "remove", "name": name, "matches": [selector], "library": library}).to_string(); + self.call_profile_rpc(&ctx)?; + Ok(()) + } + + fn bulk_add_profile_matches(&mut self, checked: Vec, library: bool) { + let names: Vec = checked.iter().map(|m| m.name.clone()).collect(); + let _ = self.do_profile_add_matches(&self.repo_manager.profiles.detail_name.clone(), names, library); + let name = self.repo_manager.profiles.detail_name.clone(); + match self.load_profile_detail(&name) { + Ok((modules, libraries)) => { + self.repo_manager.profiles.enter_detail(name, modules, libraries); + } + Err(e) => { + self.error_alert_visible = true; + self.error_alert_message = e; + } + } + } + fn load_library_index(&mut self) -> Result<(), String> { let resp = tokio::task::block_in_place(|| { tokio::runtime::Handle::current() diff --git a/src/ui/profiles.rs b/src/ui/profiles.rs new file mode 100644 index 00000000..eb9389eb --- /dev/null +++ b/src/ui/profiles.rs @@ -0,0 +1,834 @@ +use super::palette; +use super::title::{self, TitleSegment, TitleStyle}; +use crossterm::event::KeyCode; +use ratatui::layout::{Position, Rect}; +use ratatui::prelude::Buffer; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::widgets::{Block, BorderType, Borders, Clear, Widget}; +use ratatui_cheese::input::InputState; +use ratatui_glamour::color::blend_2d; +use ratatui_glamour::rule::dashed_title; +use std::cell::Cell; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ProfDetailFocus { + Modules, + Libraries, + AddModuleBtn, + AddLibraryBtn, + CloseBtn, +} + +impl ProfDetailFocus { + pub fn next(self) -> Self { + use ProfDetailFocus::*; + match self { + Modules => Libraries, + Libraries => AddModuleBtn, + AddModuleBtn => AddLibraryBtn, + AddLibraryBtn => CloseBtn, + CloseBtn => Modules, + } + } + + pub fn prev(self) -> Self { + use ProfDetailFocus::*; + match self { + Modules => CloseBtn, + Libraries => Modules, + AddModuleBtn => Libraries, + AddLibraryBtn => AddModuleBtn, + CloseBtn => AddLibraryBtn, + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ProfCreateFocus { + Input, + CreateBtn, + CancelBtn, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ProfDeleteFocus { + YesBtn, + NoBtn, +} + +#[derive(Debug)] +pub struct ResolvedModule { + pub name: String, + pub version: String, + pub descr: String, + pub selector: String, +} + +#[derive(Debug)] +pub struct ResolvedLibrary { + pub name: String, + pub kind: String, + pub checksum: String, + pub selector: String, +} + +#[derive(Debug)] +pub struct ProfilesManager { + // List + pub profiles: Vec, + pub cursor: usize, + pub scroll: Cell, + + // Detail overlay + pub detail_visible: bool, + pub detail_name: String, + pub detail_modules: Vec, + pub detail_libraries: Vec, + pub detail_mcursor: usize, + pub detail_lcursor: usize, + pub detail_focus: ProfDetailFocus, + pub detail_mscroll: Cell, + pub detail_lscroll: Cell, + + // Create overlay + pub create_visible: bool, + pub create_input: InputState, + pub create_focus: ProfCreateFocus, + + // Delete overlay + pub delete_visible: bool, + pub delete_name: String, + pub delete_focus: ProfDeleteFocus, +} + +impl Default for ProfilesManager { + fn default() -> Self { + Self { + profiles: Vec::new(), + cursor: 0, + scroll: Cell::new(0), + detail_visible: false, + detail_name: String::new(), + detail_modules: Vec::new(), + detail_libraries: Vec::new(), + detail_mcursor: 0, + detail_lcursor: 0, + detail_focus: ProfDetailFocus::Modules, + detail_mscroll: Cell::new(0), + detail_lscroll: Cell::new(0), + create_visible: false, + create_input: InputState::new(), + create_focus: ProfCreateFocus::Input, + delete_visible: false, + delete_name: String::new(), + delete_focus: ProfDeleteFocus::YesBtn, + } + } +} + +impl ProfilesManager { + // ── List key handling ── + + pub fn handle_list_key(&mut self, key: KeyCode, filter_focus: &mut bool, filter_value: &str) -> bool { + let page = 10usize; + let total = self.filtered_count(filter_value); + let max = total.saturating_sub(1); + match key { + KeyCode::Up => { + self.cursor = self.cursor.saturating_sub(1); + } + KeyCode::Down => { + self.cursor = (self.cursor + 1).min(max); + } + KeyCode::PageUp => { + self.cursor = self.cursor.saturating_sub(page); + } + KeyCode::PageDown => { + self.cursor = (self.cursor + page).min(max); + } + KeyCode::Tab => { + *filter_focus = true; + } + KeyCode::Char('/') => { + *filter_focus = true; + } + _ => return false, + } + true + } + + pub fn filtered_count(&self, filter_value: &str) -> usize { + let f = filter_value.to_lowercase(); + if f.is_empty() { + return self.profiles.len(); + } + self.profiles.iter().filter(|n| n.to_lowercase().contains(&f)).count() + } + + // ── Detail key handling ── + + pub fn handle_detail_key(&mut self, key: KeyCode) -> bool { + use ProfDetailFocus::*; + match key { + KeyCode::Esc => { + self.detail_visible = false; + } + KeyCode::Tab => { + self.detail_focus = self.detail_focus.next(); + } + KeyCode::BackTab => { + self.detail_focus = self.detail_focus.prev(); + } + KeyCode::Up => match self.detail_focus { + Modules => self.detail_mcursor = self.detail_mcursor.saturating_sub(1), + Libraries => self.detail_lcursor = self.detail_lcursor.saturating_sub(1), + _ => {} + }, + KeyCode::Down => match self.detail_focus { + Modules => { + let max = self.detail_modules.len().saturating_sub(1); + self.detail_mcursor = (self.detail_mcursor + 1).min(max); + } + Libraries => { + let max = self.detail_libraries.len().saturating_sub(1); + self.detail_lcursor = (self.detail_lcursor + 1).min(max); + } + _ => {} + }, + KeyCode::Char('d') | KeyCode::Delete => match self.detail_focus { + Modules => return false, // trigger in mod.rs to remove module + Libraries => return false, // trigger in mod.rs to remove library + _ => {} + }, + KeyCode::Enter => return false, // button actions handled in mod.rs + _ => {} + } + true + } + + pub fn detail_selected_module(&self) -> Option<&ResolvedModule> { + self.detail_modules.get(self.detail_mcursor) + } + + pub fn detail_selected_library(&self) -> Option<&ResolvedLibrary> { + self.detail_libraries.get(self.detail_lcursor) + } + + // ── Create key handling ── + + pub fn handle_create_key(&mut self, key: KeyCode) -> bool { + use ProfCreateFocus::*; + match key { + KeyCode::Esc => { + self.create_visible = false; + } + KeyCode::Tab => { + self.create_focus = match self.create_focus { + Input => CreateBtn, + CreateBtn => CancelBtn, + CancelBtn => Input, + }; + } + KeyCode::BackTab => { + self.create_focus = match self.create_focus { + Input => CancelBtn, + CreateBtn => Input, + CancelBtn => CreateBtn, + }; + } + KeyCode::Enter => return false, // handled in mod.rs + KeyCode::Backspace if self.create_focus == Input => { + self.create_input.delete_before(); + } + KeyCode::Delete if self.create_focus == Input => { + self.create_input.delete_at(); + } + KeyCode::Left if self.create_focus == Input => { + self.create_input.move_left(); + } + KeyCode::Right if self.create_focus == Input => { + self.create_input.move_right(); + } + KeyCode::Home if self.create_focus == Input => { + self.create_input.home(); + } + KeyCode::End if self.create_focus == Input => { + self.create_input.end(); + } + KeyCode::Char(c) if self.create_focus == Input => { + self.create_input.insert_char(c); + } + _ => {} + } + true + } + + // ── Delete key handling ── + + pub fn handle_delete_key(&mut self, key: KeyCode) -> bool { + use ProfDeleteFocus::*; + match key { + KeyCode::Esc => { + self.delete_visible = false; + } + KeyCode::Tab => { + self.delete_focus = match self.delete_focus { + YesBtn => NoBtn, + NoBtn => YesBtn, + }; + } + KeyCode::BackTab => { + self.delete_focus = match self.delete_focus { + YesBtn => NoBtn, + NoBtn => YesBtn, + }; + } + KeyCode::Enter => return false, // handled in mod.rs + _ => {} + } + true + } + + // ── State management ── + + pub fn enter_detail(&mut self, name: String, modules: Vec, libraries: Vec) { + self.detail_name = name; + self.detail_modules = modules; + self.detail_libraries = libraries; + self.detail_mcursor = 0; + self.detail_lcursor = 0; + self.detail_focus = ProfDetailFocus::Modules; + self.detail_mscroll.set(0); + self.detail_lscroll.set(0); + self.detail_visible = true; + } + + pub fn open_create(&mut self) { + self.create_input = InputState::new(); + self.create_focus = ProfCreateFocus::Input; + self.create_visible = true; + } + + pub fn open_delete(&mut self, name: String) { + self.delete_name = name; + self.delete_focus = ProfDeleteFocus::YesBtn; + self.delete_visible = true; + } + + pub fn selected_profile_name(&self) -> Option<&str> { + self.profiles.get(self.cursor).map(|s| s.as_str()) + } + + // ── Rendering ── + + pub fn render_list(&self, inner: Rect, buf: &mut Buffer, filter_focus: bool, filter_state: &InputState) { + if inner.height < 2 { + return; + } + + let [filter_area, list_area] = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .constraints([ratatui::layout::Constraint::Length(1), ratatui::layout::Constraint::Min(0)]) + .split(inner) + .as_ref() + .try_into() + .unwrap(); + + Self::render_filter_row(filter_area, buf, filter_focus, filter_state); + + if self.profiles.is_empty() { + let msg = "(no profiles found)"; + let x = list_area.x + (list_area.width.saturating_sub(msg.len() as u16)) / 2; + let y = list_area.y + list_area.height / 2; + buf.set_string(x, y, msg, Style::default().fg(palette::MUTED)); + return; + } + + let flt = filter_state.value().to_lowercase(); + let filtered: Vec<(usize, &String)> = + self.profiles.iter().enumerate().filter(|(_, n)| flt.is_empty() || n.to_lowercase().contains(&flt)).collect(); + + let view_h = list_area.height as usize; + let total = filtered.len(); + let max_scroll = total.saturating_sub(view_h); + let mut s = self.scroll.get(); + let cursor = self.cursor.min(total.saturating_sub(1)); + if cursor < s { + s = cursor; + } + if cursor >= s + view_h { + s = cursor.saturating_sub(view_h.saturating_sub(1)); + } + s = s.min(max_scroll); + self.scroll.set(s); + + if total == 0 { + let msg = "(no matches)"; + let x = list_area.x + (list_area.width.saturating_sub(msg.len() as u16)) / 2; + let y = list_area.y + list_area.height / 2; + buf.set_string(x, y, msg, Style::default().fg(palette::MUTED)); + return; + } + + let hl_style = Style::default().fg(palette::HIGHLIGHT).add_modifier(Modifier::BOLD); + for i in 0..view_h.min(total.saturating_sub(s)) { + let fi = s + i; + let (_oi, name) = filtered[fi]; + let ry = list_area.y + i as u16; + let sel = !filter_focus && fi == cursor; + let row_style = if sel { hl_style } else { Style::default().fg(palette::FG) }; + let prefix = if sel { " ✨ " } else { " " }; + let line = format!("{prefix}{name}"); + buf.set_string(list_area.x + 1, ry, &line, row_style); + } + + if total > view_h { + let bh = ((view_h as f64 / total as f64) * view_h as f64).max(1.0) as usize; + let by = ((s as f64 / total as f64) * (view_h - bh) as f64) as usize; + for i in 0..view_h { + let sx = list_area.right().saturating_sub(1); + let sy = list_area.y + i as u16; + if i >= by && i < by + bh { + buf.set_string(sx, sy, "█", Style::default().fg(palette::PROCESSING_HEAT)); + } else { + buf.set_string(sx, sy, "│", Style::default().fg(palette::MUTED)); + } + } + } + } + + pub fn render_detail(&self, parent: Rect, buf: &mut Buffer) { + let w = (parent.width * 80 / 100).max(60).min(parent.width.saturating_sub(2)); + let h = (parent.height * 80 / 100).clamp(14, 26); + let x = parent.x + (parent.width.saturating_sub(w)) / 2; + let y = parent.y + (parent.height.saturating_sub(h)) / 2; + let canvas = Rect { x, y, width: w, height: h }; + + Clear.render(canvas, buf); + + let grad = blend_2d(canvas.width as usize, canvas.height as usize, 10.0, &[palette::BG_1, palette::BG_2] as &[Color]); + for ry in 0..canvas.height { + for cx in 0..canvas.width { + let idx = ry as usize * canvas.width as usize + cx as usize; + if let Some(cell) = buf.cell_mut(Position::new(canvas.x + cx, canvas.y + ry)) { + cell.set_bg(grad[idx]); + } + } + } + + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(palette::PROCESSING_GLOW)) + .style(Style::default()); + let inner = block.inner(canvas); + block.render(canvas, buf); + + let title_style = TitleStyle::cyberpunk(palette::PROCESSING_GLOW); + title::overlay_gradient_title( + buf, + canvas, + &title_style, + &[TitleSegment { text: format!(" Profile: {} ", self.detail_name), bg: palette::PROCESSING_BASE, fg: palette::FG }], + ); + + if inner.height < 6 { + return; + } + + let btn_height: u16 = 3; + let content_height = inner.height.saturating_sub(btn_height); + let mod_h = content_height / 2; + let _lib_h = content_height.saturating_sub(mod_h); + + let mut row_y = inner.y; + + // ── Modules section ── + dashed_title( + Rect { x: inner.x, y: row_y, width: inner.width, height: 1 }, + buf, + " Modules ", + palette::PROCESSING, + palette::PROCESSING_GLOW, + palette::PROCESSING_DIMMED, + ); + row_y += 1; + + if mod_h > 0 { + let mod_area = Rect { x: inner.x + 1, y: row_y, width: inner.width.saturating_sub(2), height: mod_h.saturating_sub(2) }; + let active = self.detail_focus == ProfDetailFocus::Modules; + self.render_resolved_modules(mod_area, buf, active); + row_y = mod_area.bottom(); + } + + // ── Libraries section ── + if row_y + 2 <= inner.bottom() { + dashed_title( + Rect { x: inner.x, y: row_y, width: inner.width, height: 1 }, + buf, + " Libraries ", + palette::PROCESSING, + palette::PROCESSING_GLOW, + palette::PROCESSING_DIMMED, + ); + row_y += 1; + + let lib_area = Rect { + x: inner.x + 1, + y: row_y, + width: inner.width.saturating_sub(2), + height: (inner.bottom().saturating_sub(row_y)).saturating_sub(btn_height), + }; + let active = self.detail_focus == ProfDetailFocus::Libraries; + self.render_resolved_libraries(lib_area, buf, active); + } + + // ── Buttons ── + let btn_y = inner.bottom().saturating_sub(2); + let btn_labels = ["[ Add Module ]", "[ Add Library ]", "[ Close ]"]; + let btn_widths: Vec = btn_labels.iter().map(|l| l.len() as u16).collect(); + let total_btn_w: u16 = btn_widths.iter().sum::() + 4; // 2 gaps + let mut btn_x = inner.x + (inner.width.saturating_sub(total_btn_w)) / 2; + + let focus_idx = match self.detail_focus { + ProfDetailFocus::AddModuleBtn => 0, + ProfDetailFocus::AddLibraryBtn => 1, + ProfDetailFocus::CloseBtn => 2, + _ => usize::MAX, + }; + let sel_btn = Style::default().fg(palette::WHITE).bg(palette::PROCESSING_HEAT).add_modifier(Modifier::BOLD); + let unsel_btn = Style::default().fg(palette::FG).bg(palette::BG_2).add_modifier(Modifier::BOLD); + + for (i, label) in btn_labels.iter().enumerate() { + let style = if i == focus_idx { sel_btn } else { unsel_btn }; + buf.set_string(btn_x, btn_y, *label, style); + btn_x += btn_widths[i] + 2; + } + + Self::draw_shadow(buf, canvas, w, h); + } + + fn render_resolved_modules(&self, area: Rect, buf: &mut Buffer, active: bool) { + if self.detail_modules.is_empty() { + let msg = "(no modules in this profile)"; + let x = area.x + (area.width.saturating_sub(msg.len() as u16)) / 2; + let y = area.y + area.height / 2; + buf.set_string(x, y, msg, Style::default().fg(palette::MUTED)); + return; + } + + let name_w: u16 = 28; + let ver_w: u16 = 6; + let view_h = area.height as usize; + let total = self.detail_modules.len(); + let max_scroll = total.saturating_sub(view_h); + let mut s = self.detail_mscroll.get(); + let cursor = self.detail_mcursor.min(total.saturating_sub(1)); + if cursor < s { + s = cursor; + } + if cursor >= s + view_h { + s = cursor.saturating_sub(view_h.saturating_sub(1)); + } + s = s.min(max_scroll); + self.detail_mscroll.set(s); + + let hl_style = Style::default().fg(palette::HIGHLIGHT).add_modifier(Modifier::BOLD); + let muted = Style::default().fg(palette::MUTED); + + for i in 0..view_h.min(total.saturating_sub(s)) { + let idx = s + i; + let ry = area.y + i as u16; + let m = &self.detail_modules[idx]; + let sel = active && idx == cursor; + let row_style = if sel { hl_style } else { Style::default().fg(palette::FG) }; + let prefix = if sel { " ✨ " } else { " " }; + buf.set_string(area.x + 1, ry, prefix, row_style); + buf.set_string(area.x + 5, ry, truncate_str(&m.name, name_w as usize), row_style); + let ver_style = if sel { hl_style } else { Style::default().fg(palette::HIGHLIGHT) }; + buf.set_string(area.x + 5 + name_w + 1, ry, truncate_str(&m.version, ver_w as usize), ver_style); + let desc_x = area.x + 5 + name_w + 1 + ver_w + 1; + let max_desc = (area.width.saturating_sub(5 + name_w + ver_w + 3)) as usize; + let desc_style = if sel { hl_style } else { muted }; + buf.set_string(desc_x, ry, truncate_str(&m.descr, max_desc), desc_style); + } + + if total > view_h { + Self::draw_scrollbar(buf, area, s, total, view_h); + } + } + + fn render_resolved_libraries(&self, area: Rect, buf: &mut Buffer, active: bool) { + if self.detail_libraries.is_empty() { + let msg = "(no libraries in this profile)"; + let x = area.x + (area.width.saturating_sub(msg.len() as u16)) / 2; + let y = area.y + area.height / 2; + buf.set_string(x, y, msg, Style::default().fg(palette::MUTED)); + return; + } + + let kind_w: u16 = 8; + let name_w = area.width.saturating_sub(kind_w + 40); + let sum_w = 30u16; + let view_h = area.height as usize; + let total = self.detail_libraries.len(); + let max_scroll = total.saturating_sub(view_h); + let mut s = self.detail_lscroll.get(); + let cursor = self.detail_lcursor.min(total.saturating_sub(1)); + if cursor < s { + s = cursor; + } + if cursor >= s + view_h { + s = cursor.saturating_sub(view_h.saturating_sub(1)); + } + s = s.min(max_scroll); + self.detail_lscroll.set(s); + + let hl_style = Style::default().fg(palette::HIGHLIGHT).add_modifier(Modifier::BOLD); + let muted = Style::default().fg(palette::MUTED); + + for i in 0..view_h.min(total.saturating_sub(s)) { + let idx = s + i; + let ry = area.y + i as u16; + let lib = &self.detail_libraries[idx]; + let sel = active && idx == cursor; + let row_style = if sel { hl_style } else { Style::default().fg(palette::FG) }; + let prefix = if sel { " ✨ " } else { " " }; + buf.set_string(area.x + 1, ry, prefix, row_style); + let kind_style = if sel { row_style } else { Style::default().fg(palette::PROCESSING) }; + buf.set_string(area.x + 5, ry, format!(" {}", truncate_str(&lib.kind, kind_w as usize)), kind_style); + buf.set_string(area.x + 5 + kind_w + 1, ry, truncate_str(&lib.name, name_w as usize), row_style); + let sum_style = if sel { row_style } else { muted }; + let sum_x = area.x + 5 + kind_w + 1 + name_w + 1; + buf.set_string(sum_x, ry, truncate_str(&lib.checksum, sum_w as usize), sum_style); + } + + if total > view_h { + Self::draw_scrollbar(buf, area, s, total, view_h); + } + } + + pub fn render_create(&self, parent: Rect, buf: &mut Buffer) { + let w = (parent.width / 2).clamp(40, 60); + let h: u16 = 6; + let x = parent.x + (parent.width.saturating_sub(w)) / 2; + let y = parent.y + (parent.height.saturating_sub(h)) / 2; + let canvas = Rect { x, y, width: w, height: h }; + + Clear.render(canvas, buf); + + let grad = blend_2d(canvas.width as usize, canvas.height as usize, 10.0, &[palette::BG_1, palette::BG_2] as &[Color]); + for ry in 0..canvas.height { + for cx in 0..canvas.width { + let idx = ry as usize * canvas.width as usize + cx as usize; + if let Some(cell) = buf.cell_mut(Position::new(canvas.x + cx, canvas.y + ry)) { + cell.set_bg(grad[idx]); + } + } + } + + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(palette::PROCESSING_GLOW)) + .style(Style::default()); + let inner = block.inner(canvas); + block.render(canvas, buf); + + let title_style = TitleStyle::cyberpunk(palette::PROCESSING_GLOW); + title::overlay_gradient_title( + buf, + canvas, + &title_style, + &[TitleSegment { text: " Create Profile ".into(), bg: palette::PROCESSING_BASE, fg: palette::FG }], + ); + + // Name input row + let label_style = + if self.create_focus == ProfCreateFocus::Input { Style::default().fg(palette::ACCENT) } else { Style::default().fg(palette::MUTED) }; + buf.set_string(inner.x + 2, inner.y + 1, "Name:", label_style); + + let input_x = inner.x + 8; + let input_w = inner.width.saturating_sub(10); + if input_w > 0 && self.create_focus == ProfCreateFocus::Input { + let field_bg = palette::HIGHLIGHT; + for cx in input_x..input_x + input_w { + if let Some(cell) = buf.cell_mut(Position::new(cx, inner.y + 1)) { + cell.set_bg(field_bg); + } + } + } + + let mut is = InputState::new(); + is.set_value(self.create_input.value().to_string()); + is.set_focused(self.create_focus == ProfCreateFocus::Input); + let fc = self.create_input.cursor_pos(); + while is.cursor_pos() < fc { + is.move_right(); + } + let styles = ratatui_cheese::input::InputStyles { text: Style::default().fg(palette::BG_1), ..Default::default() }; + let inp = ratatui_cheese::input::Input::new("").prompt("").placeholder("profile name").styles(styles); + ratatui::widgets::StatefulWidget::render(&inp, Rect::new(input_x, inner.y + 1, input_w, 1), buf, &mut is); + + // Buttons + let btn_y = inner.y + 3; + let create_lbl = "[ Create ]"; + let cancel_lbl = "[ Cancel ]"; + let create_w = create_lbl.len() as u16; + let cancel_w = cancel_lbl.len() as u16; + let total_btn_w = create_w + cancel_w + 2; + let btn_x = inner.x + (inner.width.saturating_sub(total_btn_w)) / 2; + + let sel_btn = Style::default().fg(palette::WHITE).bg(palette::PROCESSING_HEAT).add_modifier(Modifier::BOLD); + let unsel_btn = Style::default().fg(palette::FG).bg(palette::BG_2).add_modifier(Modifier::BOLD); + + let create_style = if self.create_focus == ProfCreateFocus::CreateBtn { sel_btn } else { unsel_btn }; + let cancel_style = if self.create_focus == ProfCreateFocus::CancelBtn { sel_btn } else { unsel_btn }; + buf.set_string(btn_x, btn_y, create_lbl, create_style); + buf.set_string(btn_x + create_w + 2, btn_y, cancel_lbl, cancel_style); + + Self::draw_shadow(buf, canvas, w, h); + } + + pub fn render_delete(&self, parent: Rect, buf: &mut Buffer) { + let w = (parent.width / 2).clamp(40, 60); + let h: u16 = 6; + let x = parent.x + (parent.width.saturating_sub(w)) / 2; + let y = parent.y + (parent.height.saturating_sub(h)) / 2; + let canvas = Rect { x, y, width: w, height: h }; + + Clear.render(canvas, buf); + + let grad = blend_2d(canvas.width as usize, canvas.height as usize, 10.0, &[palette::BG_1, palette::BG_2] as &[Color]); + for ry in 0..canvas.height { + for cx in 0..canvas.width { + let idx = ry as usize * canvas.width as usize + cx as usize; + if let Some(cell) = buf.cell_mut(Position::new(canvas.x + cx, canvas.y + ry)) { + cell.set_bg(grad[idx]); + } + } + } + + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(palette::PROCESSING_GLOW)) + .style(Style::default()); + let inner = block.inner(canvas); + block.render(canvas, buf); + + let title_style = TitleStyle::cyberpunk(palette::PROCESSING_GLOW); + title::overlay_gradient_title( + buf, + canvas, + &title_style, + &[TitleSegment { text: " Delete Profile ".into(), bg: palette::ERROR_BASE, fg: palette::FG }], + ); + + // Confirm text + let msg = format!("Delete profile \"{}\"?", self.delete_name); + let x = inner.x + (inner.width.saturating_sub(msg.len() as u16)) / 2; + buf.set_string(x, inner.y + 1, &msg, Style::default().fg(palette::FG)); + + // Buttons + let btn_y = inner.y + 3; + let yes_lbl = "[ Yes ]"; + let no_lbl = "[ No ]"; + let yes_w = yes_lbl.len() as u16; + let no_w = no_lbl.len() as u16; + let total_btn_w = yes_w + no_w + 4; + let btn_x = inner.x + (inner.width.saturating_sub(total_btn_w)) / 2; + + let sel_btn = Style::default().fg(palette::WHITE).bg(palette::PROCESSING_HEAT).add_modifier(Modifier::BOLD); + let unsel_btn = Style::default().fg(palette::FG).bg(palette::BG_2).add_modifier(Modifier::BOLD); + + let yes_style = if self.delete_focus == ProfDeleteFocus::YesBtn { sel_btn } else { unsel_btn }; + let no_style = if self.delete_focus == ProfDeleteFocus::NoBtn { sel_btn } else { unsel_btn }; + buf.set_string(btn_x, btn_y, yes_lbl, yes_style); + buf.set_string(btn_x + yes_w + 2, btn_y, no_lbl, no_style); + + Self::draw_shadow(buf, canvas, w, h); + } + + // ── Helpers ── + + fn render_filter_row(area: Rect, buf: &mut Buffer, focused: bool, filter_state: &InputState) { + let label_style = if focused { Style::default().fg(palette::ACCENT) } else { Style::default().fg(palette::MUTED) }; + buf.set_string(area.x + 2, area.y, "filter: ", label_style); + + let input_x = area.x + 10; + let input_w = area.width.saturating_sub(10); + if input_w == 0 { + return; + } + + let field_bg = if focused { palette::HIGHLIGHT } else { palette::GRAY_1 }; + for cx in input_x..input_x + input_w { + if let Some(cell) = buf.cell_mut(Position::new(cx, area.y)) { + cell.set_bg(field_bg); + } + } + + let mut is = InputState::new(); + is.set_value(filter_state.value().to_string()); + is.set_focused(focused); + let fc = filter_state.cursor_pos(); + while is.cursor_pos() < fc { + is.move_right(); + } + let styles = ratatui_cheese::input::InputStyles { text: Style::default().fg(palette::BG_1), ..Default::default() }; + let inp = ratatui_cheese::input::Input::new("").prompt("").placeholder("search profiles...").styles(styles); + ratatui::widgets::StatefulWidget::render(&inp, Rect::new(input_x, area.y, input_w, 1), buf, &mut is); + } + + fn draw_scrollbar(buf: &mut Buffer, area: Rect, offset: usize, total: usize, view_h: usize) { + let bar_h = ((view_h as f64 / total.max(1) as f64) * view_h as f64).max(1.0) as usize; + let bar_h = bar_h.min(view_h); + let bar_y = ((offset as f64 / total.max(1) as f64) * (view_h - bar_h) as f64) as usize; + for i in 0..view_h { + let sx = area.right().saturating_sub(1); + let sy = area.y + i as u16; + if i >= bar_y && i < bar_y + bar_h { + buf.set_string(sx, sy, "█", Style::default().fg(palette::PROCESSING_HEAT)); + } else { + buf.set_string(sx, sy, "│", Style::default().fg(palette::MUTED)); + } + } + } + + fn draw_shadow(buf: &mut Buffer, canvas: Rect, dlg_w: u16, dlg_h: u16) { + let buf_area = buf.area(); + let x = canvas.x; + let y = canvas.y; + let max_x = buf_area.right().saturating_sub(1); + let max_y = buf_area.bottom().saturating_sub(1); + for idx in 0..dlg_w { + let sx = x.saturating_add(2).saturating_add(idx); + let sy = y.saturating_add(dlg_h); + if sx > max_x || sy > max_y { + continue; + } + if let Some(cell) = buf.cell_mut(Position::new(sx, sy)) { + cell.set_bg(palette::SHADOW_BG); + cell.set_fg(palette::SHADOW_FG); + } + } + for offset in 0..2u16 { + for idx in 0..dlg_h { + let sx = x.saturating_add(dlg_w).saturating_add(offset); + let sy = y.saturating_add(idx).saturating_add(1); + if sx > max_x || sy > max_y { + continue; + } + if let Some(cell) = buf.cell_mut(Position::new(sx, sy)) { + cell.set_bg(palette::SHADOW_BG); + cell.set_fg(palette::SHADOW_FG); + } + } + } + } +} + +fn truncate_str(s: &str, max_w: usize) -> String { + if s.len() <= max_w { s.to_string() } else { format!("{}…", &s[..max_w.saturating_sub(1)]) } +} diff --git a/src/ui/repomanager.rs b/src/ui/repomanager.rs index 629815db..43d49830 100644 --- a/src/ui/repomanager.rs +++ b/src/ui/repomanager.rs @@ -1,5 +1,5 @@ use super::{ - dslbrowser, palette, + dslbrowser, palette, profiles, title::{self, TitleSegment, TitleStyle}, }; use libsysinspect::console::{ConsoleModuleArgument, ConsoleModuleRow}; @@ -34,6 +34,14 @@ pub enum StagingFocus { Cancel, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StagingMode { + ModuleAdd, + ModuleDelete, + ProfileModuleAdd, + ProfileLibraryAdd, +} + #[derive(Debug)] pub struct RepoManager { pub visible: bool, @@ -47,6 +55,7 @@ pub struct RepoManager { pub staging_cursor: usize, pub staging_scroll: Cell, pub staging_focus: StagingFocus, + pub staging_mode: StagingMode, // Progress pub progress: Arc>>, @@ -73,6 +82,9 @@ pub struct RepoManager { pub lib_rows: Vec, pub lib_cursor: usize, pub lib_scroll: Cell, + + // Profiles + pub profiles: profiles::ProfilesManager, } impl Default for RepoManager { @@ -87,6 +99,7 @@ impl Default for RepoManager { staging_cursor: 0, staging_scroll: Cell::new(0), staging_focus: StagingFocus::List, + staging_mode: StagingMode::ModuleAdd, progress: Arc::new(Mutex::new(None)), bulk_add_triggered: false, bulk_delete_triggered: false, @@ -103,6 +116,7 @@ impl Default for RepoManager { lib_rows: Vec::new(), lib_cursor: 0, lib_scroll: Cell::new(0), + profiles: profiles::ProfilesManager::default(), } } } @@ -122,6 +136,48 @@ impl RepoManager { self.staged.clear(); } + pub fn enter_profile_module_staging(&mut self) { + self.staged = self + .rows + .iter() + .map(|r| StagedModule { + name: r.name.clone(), + version: r.version.clone(), + descr: r.descr.clone(), + path: std::path::PathBuf::new(), + checked: false, + }) + .collect(); + self.staging_cursor = 0; + self.staging_scroll = Cell::new(0); + self.staging_focus = StagingFocus::List; + self.staging_mode = StagingMode::ProfileModuleAdd; + self.profiles.detail_visible = false; + self.staging = true; + self.delete_mode = false; + } + + pub fn enter_profile_library_staging(&mut self) { + self.staged = self + .lib_rows + .iter() + .map(|r| StagedModule { + name: r.name.clone(), + version: Some(r.kind.clone()), + descr: r.checksum.clone(), + path: std::path::PathBuf::new(), + checked: false, + }) + .collect(); + self.staging_cursor = 0; + self.staging_scroll = Cell::new(0); + self.staging_focus = StagingFocus::List; + self.staging_mode = StagingMode::ProfileLibraryAdd; + self.profiles.detail_visible = false; + self.staging = true; + self.delete_mode = false; + } + pub fn handle_staging_key(&mut self, key: crossterm::event::KeyEvent) -> bool { if !self.staging { return false; @@ -189,6 +245,15 @@ impl RepoManager { if self.staging { self.render_staging(parent, buf); } + if self.profiles.detail_visible { + self.profiles.render_detail(parent, buf); + } + if self.profiles.create_visible { + self.profiles.render_create(parent, buf); + } + if self.profiles.delete_visible { + self.profiles.render_delete(parent, buf); + } } fn render_main(&self, parent: Rect, buf: &mut Buffer) { @@ -252,7 +317,7 @@ impl RepoManager { match self.active_tab { 0 => self.render_modules(body, buf), 1 => self.render_libraries(body, buf), - 2 => self.render_profiles_placeholder(body, buf), + 2 => self.profiles.render_list(body, buf, self.filter_focus, &self.filter), _ => {} } Self::draw_shadow(buf, canvas, dlg_w, dlg_h); @@ -617,13 +682,6 @@ impl RepoManager { } } - fn render_profiles_placeholder(&self, inner: Rect, buf: &mut Buffer) { - let msg = "Profiles management is not implemented yet"; - let x = inner.x + (inner.width.saturating_sub(msg.len() as u16)) / 2; - let y = inner.y + inner.height / 2; - buf.set_string(x, y, msg, Style::default().fg(palette::MUTED)); - } - fn render_filter_row(area: Rect, buf: &mut Buffer, focused: bool, filter_state: &InputState) { let label_style = if focused { Style::default().fg(palette::ACCENT) } else { Style::default().fg(palette::MUTED) }; buf.set_string(area.x + 2, area.y, "filter: ", label_style); diff --git a/src/ui/statusbar.rs b/src/ui/statusbar.rs index 570a2475..55a8f681 100644 --- a/src/ui/statusbar.rs +++ b/src/ui/statusbar.rs @@ -192,6 +192,33 @@ impl SysInspectUX { ]); } + pub(crate) fn status_at_profiles(&mut self) { + let key = |s| Span::styled(s, Style::default().fg(palette::FG)); + let desc = |s| Span::styled(s, Style::default().fg(palette::FAINT)); + + let p = &self.repo_manager.profiles; + if p.delete_visible { + self.status_text = Line::from(vec![key("Tab "), desc("switch "), key("Enter "), desc("confirm "), key("Esc "), desc("cancel")]); + } else if p.create_visible { + self.status_text = Line::from(vec![key("Tab "), desc("switch "), key("Enter "), desc("create "), key("Esc "), desc("cancel")]); + } else if p.detail_visible { + self.status_text = Line::from(vec![key("Tab "), desc("switch section "), key("d/Del "), desc("remove "), key("Esc "), desc("close")]); + } else { + self.status_text = Line::from(vec![ + key("\u{2191}\u{2193} "), + desc("navigate "), + key("Enter "), + desc("view/edit "), + key("Ins/n "), + desc("create "), + key("Del "), + desc("delete "), + key("Esc "), + desc("close"), + ]); + } + } + pub(crate) fn status_at_query_composer(&mut self) { self.status_text = Line::from(vec![ Span::styled(" Tab ", Style::default().fg(palette::FG)), From fe6f27e5ba6e7c2b1d9c850f02a375a6cea6a469 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 13 Jun 2026 00:45:49 +0200 Subject: [PATCH 17/25] Finalise profiles support in the repo manager --- src/ui/dslbrowser.rs | 24 +++- src/ui/filepicker.rs | 4 +- src/ui/macts.rs | 21 ++- src/ui/mod.rs | 68 ++++------ src/ui/online.rs | 8 +- src/ui/profiles.rs | 301 +++++++++++++++++++++++------------------- src/ui/rawlogs.rs | 16 +-- src/ui/repomanager.rs | 25 +++- src/ui/setup.rs | 2 +- src/ui/title.rs | 9 +- src/ui/traitsview.rs | 4 +- 11 files changed, 263 insertions(+), 219 deletions(-) diff --git a/src/ui/dslbrowser.rs b/src/ui/dslbrowser.rs index e65baa56..ef01ea0f 100644 --- a/src/ui/dslbrowser.rs +++ b/src/ui/dslbrowser.rs @@ -763,15 +763,20 @@ impl SysInspectUX { (palette::FG, palette::PROCESSING_GLOW, palette::PROCESSING_HEAT, palette::PROCESSING_PEAK, palette::PROCESSING) }; - let mut title_segments = vec![TitleSegment { text: " Query Composer ".into(), bg: glow_bg, fg: title_fg }]; + let mut title_segments = vec![TitleSegment { text: " Query Composer ".into(), bg: glow_bg, fg: title_fg, modifier: Modifier::empty() }]; if has_model { - title_segments.push(TitleSegment { text: format!(" {model_name} "), bg: heat_bg, fg: palette::SUCCESS_PEAK }); + title_segments.push(TitleSegment { + text: format!(" {model_name} "), + bg: heat_bg, + fg: palette::SUCCESS_PEAK, + modifier: Modifier::empty(), + }); } if has_target { - title_segments.push(TitleSegment { text: format!(" {target_id} "), bg: peak_bg, fg: palette::BG_2 }); + title_segments.push(TitleSegment { text: format!(" {target_id} "), bg: peak_bg, fg: palette::BG_2, modifier: Modifier::empty() }); } if has_state { - title_segments.push(TitleSegment { text: format!(" {state_display} "), bg: proc_bg, fg: palette::BG_3 }); + title_segments.push(TitleSegment { text: format!(" {state_display} "), bg: proc_bg, fg: palette::BG_3, modifier: Modifier::empty() }); } let border_color = glow_bg; @@ -861,11 +866,16 @@ impl SysInspectUX { let title_style = TitleStyle::cyberpunk(palette::PROCESSING_GLOW); let mut segments = vec![ - TitleSegment { text: " Details on ".into(), bg: palette::PROCESSING_GLOW, fg: palette::FG }, - TitleSegment { text: format!(" {model_name} "), bg: palette::PROCESSING_HEAT, fg: palette::SUCCESS_PEAK }, + TitleSegment { text: " Details on ".into(), bg: palette::PROCESSING_GLOW, fg: palette::FG, modifier: Modifier::empty() }, + TitleSegment { text: format!(" {model_name} "), bg: palette::PROCESSING_HEAT, fg: palette::SUCCESS_PEAK, modifier: Modifier::empty() }, ]; if has_target { - segments.push(TitleSegment { text: format!(" {target_id} "), bg: palette::PROCESSING_PEAK, fg: palette::SUCCESS_PEAK }); + segments.push(TitleSegment { + text: format!(" {target_id} "), + bg: palette::PROCESSING_PEAK, + fg: palette::SUCCESS_PEAK, + modifier: Modifier::empty(), + }); } title::overlay_gradient_title(buf, canvas, &title_style, segments.as_slice()); diff --git a/src/ui/filepicker.rs b/src/ui/filepicker.rs index 058d8f96..cbc0a1cb 100644 --- a/src/ui/filepicker.rs +++ b/src/ui/filepicker.rs @@ -466,9 +466,9 @@ impl FilePicker { let path_str = self.current_path.to_string_lossy().to_string(); let folded = if path_avail > 0 { fold_path_fish_style(&path_str, path_avail) } else { String::new() }; - let mut segments = vec![TitleSegment { text: title_text.into(), bg: palette::PROCESSING_BASE, fg: palette::FG }]; + let mut segments = vec![TitleSegment { text: title_text.into(), bg: palette::PROCESSING_BASE, fg: palette::FG, modifier: Modifier::empty() }]; if !folded.is_empty() { - segments.push(TitleSegment { text: folded, bg: palette::PROCESSING_HEAT, fg: palette::FG }); + segments.push(TitleSegment { text: folded, bg: palette::PROCESSING_HEAT, fg: palette::FG, modifier: Modifier::empty() }); } title::overlay_gradient_title(buf, canvas, &title_style, &segments); diff --git a/src/ui/macts.rs b/src/ui/macts.rs index e3a7d198..b9344668 100644 --- a/src/ui/macts.rs +++ b/src/ui/macts.rs @@ -5,7 +5,7 @@ use super::{ use ratatui::{ layout::Position, prelude::{Buffer, Rect}, - style::Style, + style::{Modifier, Style}, widgets::{Block, BorderType, Borders, Clear, Widget}, }; use ratatui_glamour::color::blend_2d; @@ -193,13 +193,20 @@ impl SysInspectUX { let mut title_style = TitleStyle::cyberpunk(palette::PROCESSING_GLOW); let is_cluster = self.minions_menu_sel >= 6; - let mut segments = vec![TitleSegment { text: " Actions on ".into(), bg: palette::PROCESSING_GLOW, fg: palette::FG }]; + let mut segments = + vec![TitleSegment { text: " Actions on ".into(), bg: palette::PROCESSING_GLOW, fg: palette::FG, modifier: Modifier::empty() }]; if is_cluster { - segments.push(TitleSegment { text: " Cluster ".into(), bg: palette::PROCESSING_PEAK, fg: palette::FG }); - segments.push(TitleSegment { text: " ⚡⚡⚡ ".into(), bg: palette::ERROR_PEAK, fg: palette::WARNING_PEAK }); + segments.push(TitleSegment { text: " Cluster ".into(), bg: palette::PROCESSING_PEAK, fg: palette::FG, modifier: Modifier::empty() }); + segments + .push(TitleSegment { text: " ⚡⚡⚡ ".into(), bg: palette::ERROR_PEAK, fg: palette::WARNING_PEAK, modifier: Modifier::empty() }); title_style.gradient_target = Some(palette::ERROR_BASE); } else { - segments.push(TitleSegment { text: format!(" {host} "), bg: palette::PROCESSING_HEAT, fg: palette::SUCCESS_PEAK }); + segments.push(TitleSegment { + text: format!(" {host} "), + bg: palette::PROCESSING_HEAT, + fg: palette::SUCCESS_PEAK, + modifier: Modifier::empty(), + }); } render_menu_popup(parent, buf, MENU_SECTIONS, self.minions_menu_sel, &segments, &title_style, max_item_w, &[]); @@ -218,8 +225,8 @@ impl SysInspectUX { let is_system = self.master_menu_sel >= 4; let sub_title = if is_system { " System " } else { " Operations " }; let segments = vec![ - TitleSegment { text: " Master ".into(), bg: palette::PROCESSING_GLOW, fg: palette::FG }, - TitleSegment { text: sub_title.into(), bg: palette::PROCESSING_HEAT, fg: palette::FG }, + TitleSegment { text: " Master ".into(), bg: palette::PROCESSING_GLOW, fg: palette::FG, modifier: Modifier::empty() }, + TitleSegment { text: sub_title.into(), bg: palette::PROCESSING_HEAT, fg: palette::FG, modifier: Modifier::empty() }, ]; let local_logs_available = self.cfg.logfile_std().exists() || self.cfg.logfile_err().exists(); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 2917d26e..1e2f56b4 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1811,7 +1811,7 @@ impl SysInspectUX { self.repo_manager.filter_focus = false; self.repo_manager.cursor = 0; } - KeyCode::Tab | KeyCode::BackTab => { + KeyCode::Down | KeyCode::Tab | KeyCode::BackTab => { self.repo_manager.filter_focus = false; self.repo_manager.cursor = 0; } @@ -1880,44 +1880,16 @@ impl SysInspectUX { } if self.repo_manager.profiles.detail_visible { let handled = self.repo_manager.profiles.handle_detail_key(e.code); - if !handled { - match e.code { - KeyCode::Enter => match self.repo_manager.profiles.detail_focus { - profiles::ProfDetailFocus::AddModuleBtn => { - self.repo_manager.enter_profile_module_staging(); - } - profiles::ProfDetailFocus::AddLibraryBtn => { - self.repo_manager.enter_profile_library_staging(); - } - profiles::ProfDetailFocus::CloseBtn => { - self.repo_manager.profiles.detail_visible = false; - self.status_at_profiles(); - } - _ => {} - }, - KeyCode::Char('d') | KeyCode::Delete => { - let name = self.repo_manager.profiles.detail_name.clone(); - match self.repo_manager.profiles.detail_focus { - profiles::ProfDetailFocus::Modules => { - if let Some(m) = self.repo_manager.profiles.detail_selected_module() { - let sel = m.selector.clone(); - let _ = self.do_profile_remove_match(&name, &sel, false); - if let Ok((mods, libs)) = self.load_profile_detail(&name) { - self.repo_manager.profiles.enter_detail(name, mods, libs); - } - } - } - profiles::ProfDetailFocus::Libraries => { - if let Some(l) = self.repo_manager.profiles.detail_selected_library() { - let sel = l.selector.clone(); - let _ = self.do_profile_remove_match(&name, &sel, true); - if let Ok((mods, libs)) = self.load_profile_detail(&name) { - self.repo_manager.profiles.enter_detail(name, mods, libs); - } - } - } - _ => {} - } + if !handled && e.code == KeyCode::Enter { + match self.repo_manager.profiles.detail_focus { + profiles::ProfDetailFocus::AddModuleBtn => { + self.repo_manager.enter_profile_module_staging(); + } + profiles::ProfDetailFocus::AddLibraryBtn => { + self.repo_manager.enter_profile_library_staging(); + } + profiles::ProfDetailFocus::CloseBtn => { + self.repo_manager.profiles.detail_visible = false; self.status_at_profiles(); } _ => {} @@ -1961,7 +1933,7 @@ impl SysInspectUX { } } KeyCode::Right => { - self.repo_manager.active_tab = (self.repo_manager.active_tab + 1).min(2); + self.repo_manager.active_tab = (self.repo_manager.active_tab + 1).min(3); self.repo_manager.cursor = 0; self.repo_manager.lib_cursor = 0; self.repo_manager.profiles.cursor = 0; @@ -2127,20 +2099,21 @@ impl SysInspectUX { fn load_profile_detail(&mut self, name: &str) -> Result<(Vec, Vec), String> { let ctx_mods = serde_json::json!({"op": "list", "name": name, "library": false}).to_string(); let payload_mods = self.call_profile_rpc(&ctx_mods)?; - let module_selectors = match payload_mods { + let module_selectors: Vec = match payload_mods { ConsolePayload::StringList { items } => items, _ => return Err("Unexpected payload for profile module selectors".to_string()), }; let ctx_libs = serde_json::json!({"op": "list", "name": name, "library": true}).to_string(); let payload_libs = self.call_profile_rpc(&ctx_libs)?; - let library_selectors = match payload_libs { + let library_selectors: Vec = match payload_libs { ConsolePayload::StringList { items } => items, _ => return Err("Unexpected payload for profile library selectors".to_string()), }; let resolved_modules: Vec = module_selectors .iter() + .filter_map(|s| s.split_once(": ").map(|x| x.1)) .flat_map(|sel| { self.repo_manager .rows @@ -2150,7 +2123,7 @@ impl SysInspectUX { name: r.name.clone(), version: r.version.clone().unwrap_or_default(), descr: r.descr.clone(), - selector: sel.clone(), + selector: sel.to_string(), }) .collect::>() }) @@ -2158,6 +2131,7 @@ impl SysInspectUX { let resolved_libraries: Vec = library_selectors .iter() + .filter_map(|s| s.split_once(": ").map(|x| x.1)) .flat_map(|sel| { self.repo_manager .lib_rows @@ -2167,7 +2141,7 @@ impl SysInspectUX { name: r.name.clone(), kind: r.kind.clone(), checksum: r.checksum.clone(), - selector: sel.clone(), + selector: sel.to_string(), }) .collect::>() }) @@ -2204,8 +2178,12 @@ impl SysInspectUX { fn bulk_add_profile_matches(&mut self, checked: Vec, library: bool) { let names: Vec = checked.iter().map(|m| m.name.clone()).collect(); - let _ = self.do_profile_add_matches(&self.repo_manager.profiles.detail_name.clone(), names, library); let name = self.repo_manager.profiles.detail_name.clone(); + if let Err(e) = self.do_profile_add_matches(&name, names, library) { + self.error_alert_visible = true; + self.error_alert_message = e; + return; + } match self.load_profile_detail(&name) { Ok((modules, libraries)) => { self.repo_manager.profiles.enter_detail(name, modules, libraries); diff --git a/src/ui/online.rs b/src/ui/online.rs index 0ef6a4cc..7c1e256c 100644 --- a/src/ui/online.rs +++ b/src/ui/online.rs @@ -93,10 +93,10 @@ impl SysInspectUX { parent, &title_style, &[ - TitleSegment { text: " Minions ".into(), bg: bg_base, fg: fg_dim }, - TitleSegment { text: format!(" {n_online} online "), bg: bg_glow, fg: palette::SUCCESS }, - TitleSegment { text: format!(" {n_offline} offline "), bg: bg_heat, fg: palette::WARNING }, - TitleSegment { text: format!(" {} total ", n_online + n_offline), bg: bg_peak, fg: fg_dim }, + TitleSegment { text: " Minions ".into(), bg: bg_base, fg: fg_dim, modifier: Modifier::empty() }, + TitleSegment { text: format!(" {n_online} online "), bg: bg_glow, fg: palette::SUCCESS, modifier: Modifier::empty() }, + TitleSegment { text: format!(" {n_offline} offline "), bg: bg_heat, fg: palette::WARNING, modifier: Modifier::empty() }, + TitleSegment { text: format!(" {} total ", n_online + n_offline), bg: bg_peak, fg: fg_dim, modifier: Modifier::empty() }, ], ); diff --git a/src/ui/profiles.rs b/src/ui/profiles.rs index eb9389eb..ceb2eeaf 100644 --- a/src/ui/profiles.rs +++ b/src/ui/profiles.rs @@ -20,25 +20,41 @@ pub enum ProfDetailFocus { } impl ProfDetailFocus { - pub fn next(self) -> Self { + pub fn next(self, has_modules: bool, has_libraries: bool) -> Self { use ProfDetailFocus::*; - match self { - Modules => Libraries, - Libraries => AddModuleBtn, - AddModuleBtn => AddLibraryBtn, - AddLibraryBtn => CloseBtn, - CloseBtn => Modules, + let mut cur = self; + loop { + cur = match cur { + Modules => Libraries, + Libraries => AddModuleBtn, + AddModuleBtn => AddLibraryBtn, + AddLibraryBtn => CloseBtn, + CloseBtn => Modules, + }; + match cur { + Modules if !has_modules => continue, + Libraries if !has_libraries => continue, + _ => return cur, + } } } - pub fn prev(self) -> Self { + pub fn prev(self, has_modules: bool, has_libraries: bool) -> Self { use ProfDetailFocus::*; - match self { - Modules => CloseBtn, - Libraries => Modules, - AddModuleBtn => Libraries, - AddLibraryBtn => AddModuleBtn, - CloseBtn => AddLibraryBtn, + let mut cur = self; + loop { + cur = match cur { + Modules => CloseBtn, + Libraries => Modules, + AddModuleBtn => Libraries, + AddLibraryBtn => AddModuleBtn, + CloseBtn => AddLibraryBtn, + }; + match cur { + Modules if !has_modules => continue, + Libraries if !has_libraries => continue, + _ => return cur, + } } } } @@ -84,11 +100,9 @@ pub struct ProfilesManager { pub detail_name: String, pub detail_modules: Vec, pub detail_libraries: Vec, - pub detail_mcursor: usize, - pub detail_lcursor: usize, pub detail_focus: ProfDetailFocus, - pub detail_mscroll: Cell, - pub detail_lscroll: Cell, + pub detail_moffset: Cell, + pub detail_loffset: Cell, // Create overlay pub create_visible: bool, @@ -111,11 +125,9 @@ impl Default for ProfilesManager { detail_name: String::new(), detail_modules: Vec::new(), detail_libraries: Vec::new(), - detail_mcursor: 0, - detail_lcursor: 0, detail_focus: ProfDetailFocus::Modules, - detail_mscroll: Cell::new(0), - detail_lscroll: Cell::new(0), + detail_moffset: Cell::new(0), + detail_loffset: Cell::new(0), create_visible: false, create_input: InputState::new(), create_focus: ProfCreateFocus::Input, @@ -174,46 +186,71 @@ impl ProfilesManager { self.detail_visible = false; } KeyCode::Tab => { - self.detail_focus = self.detail_focus.next(); + let hm = !self.detail_modules.is_empty(); + let hl = !self.detail_libraries.is_empty(); + self.detail_focus = self.detail_focus.next(hm, hl); } KeyCode::BackTab => { - self.detail_focus = self.detail_focus.prev(); + let hm = !self.detail_modules.is_empty(); + let hl = !self.detail_libraries.is_empty(); + self.detail_focus = self.detail_focus.prev(hm, hl); } KeyCode::Up => match self.detail_focus { - Modules => self.detail_mcursor = self.detail_mcursor.saturating_sub(1), - Libraries => self.detail_lcursor = self.detail_lcursor.saturating_sub(1), + Modules => { + let o = self.detail_moffset.get(); + self.detail_moffset.set(o.saturating_sub(1)); + } + Libraries => { + let o = self.detail_loffset.get(); + self.detail_loffset.set(o.saturating_sub(1)); + } _ => {} }, KeyCode::Down => match self.detail_focus { Modules => { - let max = self.detail_modules.len().saturating_sub(1); - self.detail_mcursor = (self.detail_mcursor + 1).min(max); + let o = self.detail_moffset.get(); + let view_h = 10usize; // approximate, clamped in render + let max = self.detail_modules.len().saturating_sub(view_h); + self.detail_moffset.set((o + 1).min(max)); } Libraries => { - let max = self.detail_libraries.len().saturating_sub(1); - self.detail_lcursor = (self.detail_lcursor + 1).min(max); + let o = self.detail_loffset.get(); + let view_h = 10usize; + let max = self.detail_libraries.len().saturating_sub(view_h); + self.detail_loffset.set((o + 1).min(max)); } _ => {} }, - KeyCode::Char('d') | KeyCode::Delete => match self.detail_focus { - Modules => return false, // trigger in mod.rs to remove module - Libraries => return false, // trigger in mod.rs to remove library + KeyCode::PageUp => match self.detail_focus { + Modules => { + let o = self.detail_moffset.get(); + self.detail_moffset.set(o.saturating_sub(10)); + } + Libraries => { + let o = self.detail_loffset.get(); + self.detail_loffset.set(o.saturating_sub(10)); + } + _ => {} + }, + KeyCode::PageDown => match self.detail_focus { + Modules => { + let o = self.detail_moffset.get(); + let max = self.detail_modules.len().saturating_sub(10); + self.detail_moffset.set((o + 10).min(max)); + } + Libraries => { + let o = self.detail_loffset.get(); + let max = self.detail_libraries.len().saturating_sub(10); + self.detail_loffset.set((o + 10).min(max)); + } _ => {} }, - KeyCode::Enter => return false, // button actions handled in mod.rs + KeyCode::Enter => return false, _ => {} } true } - pub fn detail_selected_module(&self) -> Option<&ResolvedModule> { - self.detail_modules.get(self.detail_mcursor) - } - - pub fn detail_selected_library(&self) -> Option<&ResolvedLibrary> { - self.detail_libraries.get(self.detail_lcursor) - } - // ── Create key handling ── pub fn handle_create_key(&mut self, key: KeyCode) -> bool { @@ -295,11 +332,15 @@ impl ProfilesManager { self.detail_name = name; self.detail_modules = modules; self.detail_libraries = libraries; - self.detail_mcursor = 0; - self.detail_lcursor = 0; - self.detail_focus = ProfDetailFocus::Modules; - self.detail_mscroll.set(0); - self.detail_lscroll.set(0); + self.detail_focus = if !self.detail_modules.is_empty() { + ProfDetailFocus::Modules + } else if !self.detail_libraries.is_empty() { + ProfDetailFocus::Libraries + } else { + ProfDetailFocus::AddModuleBtn + }; + self.detail_moffset.set(0); + self.detail_loffset.set(0); self.detail_visible = true; } @@ -406,7 +447,7 @@ impl ProfilesManager { Clear.render(canvas, buf); - let grad = blend_2d(canvas.width as usize, canvas.height as usize, 10.0, &[palette::BG_1, palette::BG_2] as &[Color]); + let grad = blend_2d(canvas.width as usize, canvas.height as usize, 10.0, &[palette::BG_1, palette::BG_0] as &[Color]); for ry in 0..canvas.height { for cx in 0..canvas.width { let idx = ry as usize * canvas.width as usize + cx as usize; @@ -425,12 +466,24 @@ impl ProfilesManager { block.render(canvas, buf); let title_style = TitleStyle::cyberpunk(palette::PROCESSING_GLOW); - title::overlay_gradient_title( - buf, - canvas, - &title_style, - &[TitleSegment { text: format!(" Profile: {} ", self.detail_name), bg: palette::PROCESSING_BASE, fg: palette::FG }], - ); + let focus_text = match self.detail_focus { + ProfDetailFocus::Modules => Some(" Modules "), + ProfDetailFocus::Libraries => Some(" Libraries "), + _ => None, + }; + let mut title_segments = vec![ + TitleSegment { text: " Profile ".into(), bg: palette::PROCESSING_BASE, fg: palette::FG, modifier: Modifier::empty() }, + TitleSegment { + text: format!(" {} ", self.detail_name), + bg: palette::PROCESSING_GLOW, + fg: palette::SUCCESS_PEAK, + modifier: Modifier::BOLD, + }, + ]; + if let Some(ft) = focus_text { + title_segments.push(TitleSegment { text: ft.into(), bg: palette::PROCESSING_HEAT, fg: palette::FG, modifier: Modifier::empty() }); + } + title::overlay_gradient_title(buf, canvas, &title_style, &title_segments); if inner.height < 6 { return; @@ -449,16 +502,15 @@ impl ProfilesManager { buf, " Modules ", palette::PROCESSING, - palette::PROCESSING_GLOW, + palette::PROCESSING_PEAK, palette::PROCESSING_DIMMED, ); row_y += 1; if mod_h > 0 { - let mod_area = Rect { x: inner.x + 1, y: row_y, width: inner.width.saturating_sub(2), height: mod_h.saturating_sub(2) }; - let active = self.detail_focus == ProfDetailFocus::Modules; - self.render_resolved_modules(mod_area, buf, active); - row_y = mod_area.bottom(); + let mod_area = Rect { x: inner.x, y: row_y, width: inner.width.saturating_sub(1), height: mod_h.saturating_sub(1) }; + self.render_resolved_modules(mod_area, buf, self.detail_focus == ProfDetailFocus::Modules); + row_y = mod_area.bottom() + 1; } // ── Libraries section ── @@ -468,19 +520,18 @@ impl ProfilesManager { buf, " Libraries ", palette::PROCESSING, - palette::PROCESSING_GLOW, + palette::PROCESSING_PEAK, palette::PROCESSING_DIMMED, ); row_y += 1; let lib_area = Rect { - x: inner.x + 1, + x: inner.x, y: row_y, - width: inner.width.saturating_sub(2), + width: inner.width.saturating_sub(1), height: (inner.bottom().saturating_sub(row_y)).saturating_sub(btn_height), }; - let active = self.detail_focus == ProfDetailFocus::Libraries; - self.render_resolved_libraries(lib_area, buf, active); + self.render_resolved_libraries(lib_area, buf, self.detail_focus == ProfDetailFocus::Libraries); } // ── Buttons ── @@ -496,8 +547,8 @@ impl ProfilesManager { ProfDetailFocus::CloseBtn => 2, _ => usize::MAX, }; - let sel_btn = Style::default().fg(palette::WHITE).bg(palette::PROCESSING_HEAT).add_modifier(Modifier::BOLD); - let unsel_btn = Style::default().fg(palette::FG).bg(palette::BG_2).add_modifier(Modifier::BOLD); + let sel_btn = Style::default().fg(palette::WHITE).bg(palette::PROCESSING_HEAT); + let unsel_btn = Style::default().fg(palette::FG).bg(palette::BG_2); for (i, label) in btn_labels.iter().enumerate() { let style = if i == focus_idx { sel_btn } else { unsel_btn }; @@ -508,12 +559,13 @@ impl ProfilesManager { Self::draw_shadow(buf, canvas, w, h); } - fn render_resolved_modules(&self, area: Rect, buf: &mut Buffer, active: bool) { + fn render_resolved_modules(&self, area: Rect, buf: &mut Buffer, focused: bool) { if self.detail_modules.is_empty() { let msg = "(no modules in this profile)"; let x = area.x + (area.width.saturating_sub(msg.len() as u16)) / 2; let y = area.y + area.height / 2; buf.set_string(x, y, msg, Style::default().fg(palette::MUTED)); + Self::draw_scrollbar(buf, area, 0, 1, area.height as usize, focused); return; } @@ -522,48 +574,34 @@ impl ProfilesManager { let view_h = area.height as usize; let total = self.detail_modules.len(); let max_scroll = total.saturating_sub(view_h); - let mut s = self.detail_mscroll.get(); - let cursor = self.detail_mcursor.min(total.saturating_sub(1)); - if cursor < s { - s = cursor; - } - if cursor >= s + view_h { - s = cursor.saturating_sub(view_h.saturating_sub(1)); - } - s = s.min(max_scroll); - self.detail_mscroll.set(s); - - let hl_style = Style::default().fg(palette::HIGHLIGHT).add_modifier(Modifier::BOLD); - let muted = Style::default().fg(palette::MUTED); + let s = self.detail_moffset.get().min(max_scroll); + self.detail_moffset.set(s); for i in 0..view_h.min(total.saturating_sub(s)) { let idx = s + i; let ry = area.y + i as u16; let m = &self.detail_modules[idx]; - let sel = active && idx == cursor; - let row_style = if sel { hl_style } else { Style::default().fg(palette::FG) }; - let prefix = if sel { " ✨ " } else { " " }; - buf.set_string(area.x + 1, ry, prefix, row_style); - buf.set_string(area.x + 5, ry, truncate_str(&m.name, name_w as usize), row_style); - let ver_style = if sel { hl_style } else { Style::default().fg(palette::HIGHLIGHT) }; - buf.set_string(area.x + 5 + name_w + 1, ry, truncate_str(&m.version, ver_w as usize), ver_style); - let desc_x = area.x + 5 + name_w + 1 + ver_w + 1; - let max_desc = (area.width.saturating_sub(5 + name_w + ver_w + 3)) as usize; - let desc_style = if sel { hl_style } else { muted }; - buf.set_string(desc_x, ry, truncate_str(&m.descr, max_desc), desc_style); - } - - if total > view_h { - Self::draw_scrollbar(buf, area, s, total, view_h); - } + let fg = if focused { palette::FG } else { palette::MUTED }; + let ver_fg = if focused { palette::HIGHLIGHT } else { palette::MUTED }; + let desc_fg = if focused { palette::GRAY_1 } else { palette::MUTED }; + let row_style = Style::default().fg(fg); + buf.set_string(area.x + 2, ry, truncate_str(&m.name, name_w as usize), row_style); + buf.set_string(area.x + 2 + name_w + 1, ry, truncate_str(&m.version, ver_w as usize), Style::default().fg(ver_fg)); + let desc_x = area.x + 2 + name_w + 1 + ver_w + 1; + let max_desc = (area.width.saturating_sub(2 + name_w + ver_w + 3)) as usize; + buf.set_string(desc_x, ry, truncate_str(&m.descr, max_desc), Style::default().fg(desc_fg)); + } + + Self::draw_scrollbar(buf, area, s, total, view_h, focused); } - fn render_resolved_libraries(&self, area: Rect, buf: &mut Buffer, active: bool) { + fn render_resolved_libraries(&self, area: Rect, buf: &mut Buffer, focused: bool) { if self.detail_libraries.is_empty() { let msg = "(no libraries in this profile)"; let x = area.x + (area.width.saturating_sub(msg.len() as u16)) / 2; let y = area.y + area.height / 2; buf.set_string(x, y, msg, Style::default().fg(palette::MUTED)); + Self::draw_scrollbar(buf, area, 0, 1, area.height as usize, focused); return; } @@ -573,39 +611,24 @@ impl ProfilesManager { let view_h = area.height as usize; let total = self.detail_libraries.len(); let max_scroll = total.saturating_sub(view_h); - let mut s = self.detail_lscroll.get(); - let cursor = self.detail_lcursor.min(total.saturating_sub(1)); - if cursor < s { - s = cursor; - } - if cursor >= s + view_h { - s = cursor.saturating_sub(view_h.saturating_sub(1)); - } - s = s.min(max_scroll); - self.detail_lscroll.set(s); - - let hl_style = Style::default().fg(palette::HIGHLIGHT).add_modifier(Modifier::BOLD); - let muted = Style::default().fg(palette::MUTED); + let s = self.detail_loffset.get().min(max_scroll); + self.detail_loffset.set(s); for i in 0..view_h.min(total.saturating_sub(s)) { let idx = s + i; let ry = area.y + i as u16; let lib = &self.detail_libraries[idx]; - let sel = active && idx == cursor; - let row_style = if sel { hl_style } else { Style::default().fg(palette::FG) }; - let prefix = if sel { " ✨ " } else { " " }; - buf.set_string(area.x + 1, ry, prefix, row_style); - let kind_style = if sel { row_style } else { Style::default().fg(palette::PROCESSING) }; - buf.set_string(area.x + 5, ry, format!(" {}", truncate_str(&lib.kind, kind_w as usize)), kind_style); - buf.set_string(area.x + 5 + kind_w + 1, ry, truncate_str(&lib.name, name_w as usize), row_style); - let sum_style = if sel { row_style } else { muted }; - let sum_x = area.x + 5 + kind_w + 1 + name_w + 1; - buf.set_string(sum_x, ry, truncate_str(&lib.checksum, sum_w as usize), sum_style); + let fg = if focused { palette::FG } else { palette::MUTED }; + let kind_fg = if focused { palette::PROCESSING } else { palette::MUTED }; + let sum_fg = if focused { palette::GRAY_1 } else { palette::MUTED }; + let row_style = Style::default().fg(fg); + buf.set_string(area.x + 2, ry, truncate_str(&lib.kind, kind_w as usize), Style::default().fg(kind_fg)); + buf.set_string(area.x + 2 + kind_w + 1, ry, truncate_str(&lib.name, name_w as usize), row_style); + let sum_x = area.x + 2 + kind_w + 1 + name_w + 1; + buf.set_string(sum_x, ry, truncate_str(&lib.checksum, sum_w as usize), Style::default().fg(sum_fg)); } - if total > view_h { - Self::draw_scrollbar(buf, area, s, total, view_h); - } + Self::draw_scrollbar(buf, area, s, total, view_h, focused); } pub fn render_create(&self, parent: Rect, buf: &mut Buffer) { @@ -640,7 +663,7 @@ impl ProfilesManager { buf, canvas, &title_style, - &[TitleSegment { text: " Create Profile ".into(), bg: palette::PROCESSING_BASE, fg: palette::FG }], + &[TitleSegment { text: " Create Profile ".into(), bg: palette::PROCESSING_BASE, fg: palette::FG, modifier: Modifier::empty() }], ); // Name input row @@ -679,8 +702,8 @@ impl ProfilesManager { let total_btn_w = create_w + cancel_w + 2; let btn_x = inner.x + (inner.width.saturating_sub(total_btn_w)) / 2; - let sel_btn = Style::default().fg(palette::WHITE).bg(palette::PROCESSING_HEAT).add_modifier(Modifier::BOLD); - let unsel_btn = Style::default().fg(palette::FG).bg(palette::BG_2).add_modifier(Modifier::BOLD); + let sel_btn = Style::default().fg(palette::WHITE).bg(palette::PROCESSING_HEAT); + let unsel_btn = Style::default().fg(palette::FG).bg(palette::BG_2); let create_style = if self.create_focus == ProfCreateFocus::CreateBtn { sel_btn } else { unsel_btn }; let cancel_style = if self.create_focus == ProfCreateFocus::CancelBtn { sel_btn } else { unsel_btn }; @@ -722,7 +745,7 @@ impl ProfilesManager { buf, canvas, &title_style, - &[TitleSegment { text: " Delete Profile ".into(), bg: palette::ERROR_BASE, fg: palette::FG }], + &[TitleSegment { text: " Delete Profile ".into(), bg: palette::ERROR_BASE, fg: palette::FG, modifier: Modifier::empty() }], ); // Confirm text @@ -739,8 +762,8 @@ impl ProfilesManager { let total_btn_w = yes_w + no_w + 4; let btn_x = inner.x + (inner.width.saturating_sub(total_btn_w)) / 2; - let sel_btn = Style::default().fg(palette::WHITE).bg(palette::PROCESSING_HEAT).add_modifier(Modifier::BOLD); - let unsel_btn = Style::default().fg(palette::FG).bg(palette::BG_2).add_modifier(Modifier::BOLD); + let sel_btn = Style::default().fg(palette::WHITE).bg(palette::PROCESSING_HEAT); + let unsel_btn = Style::default().fg(palette::FG).bg(palette::BG_2); let yes_style = if self.delete_focus == ProfDeleteFocus::YesBtn { sel_btn } else { unsel_btn }; let no_style = if self.delete_focus == ProfDeleteFocus::NoBtn { sel_btn } else { unsel_btn }; @@ -781,15 +804,27 @@ impl ProfilesManager { ratatui::widgets::StatefulWidget::render(&inp, Rect::new(input_x, area.y, input_w, 1), buf, &mut is); } - fn draw_scrollbar(buf: &mut Buffer, area: Rect, offset: usize, total: usize, view_h: usize) { - let bar_h = ((view_h as f64 / total.max(1) as f64) * view_h as f64).max(1.0) as usize; + fn draw_scrollbar(buf: &mut Buffer, area: Rect, offset: usize, total: usize, view_h: usize, focused: bool) { + if view_h == 0 { + return; + } + if total <= view_h { + for i in 0..view_h { + let sx = area.right().saturating_sub(1); + let sy = area.y + i as u16; + buf.set_string(sx, sy, "│", Style::default().fg(palette::MUTED)); + } + return; + } + let bar_h = ((view_h as f64 / total as f64) * view_h as f64).max(2.0) as usize; let bar_h = bar_h.min(view_h); - let bar_y = ((offset as f64 / total.max(1) as f64) * (view_h - bar_h) as f64) as usize; + let bar_y = ((offset as f64 / (total - view_h).max(1) as f64) * (view_h - bar_h) as f64) as usize; + let thumb_fg = if focused { palette::PROCESSING_HEAT } else { palette::PROCESSING_BASE }; for i in 0..view_h { let sx = area.right().saturating_sub(1); let sy = area.y + i as u16; if i >= bar_y && i < bar_y + bar_h { - buf.set_string(sx, sy, "█", Style::default().fg(palette::PROCESSING_HEAT)); + buf.set_string(sx, sy, "█", Style::default().fg(thumb_fg)); } else { buf.set_string(sx, sy, "│", Style::default().fg(palette::MUTED)); } diff --git a/src/ui/rawlogs.rs b/src/ui/rawlogs.rs index 12cd4a2a..f36b8ca4 100644 --- a/src/ui/rawlogs.rs +++ b/src/ui/rawlogs.rs @@ -35,12 +35,12 @@ impl SysInspectUX { let (kind_bg, kind_fg) = if self.minion_logs_online { (palette::PROCESSING_PEAK, palette::FG) } else { (palette::GRAY_2, palette::FG) }; let (poll_bg, poll_fg) = if self.minion_logs_online { (palette::PROCESSING, palette::BG_1) } else { (palette::FG, palette::BG_1) }; let mut segments = vec![ - TitleSegment { text: " Logs ".into(), bg: logs_bg, fg: logs_fg }, - TitleSegment { text: format!(" {} ", self.minion_logs_host), bg: host_bg, fg: host_fg }, - TitleSegment { text: format!(" {} ", self.minion_logs_source_kind), bg: kind_bg, fg: kind_fg }, + TitleSegment { text: " Logs ".into(), bg: logs_bg, fg: logs_fg, modifier: Modifier::empty() }, + TitleSegment { text: format!(" {} ", self.minion_logs_host), bg: host_bg, fg: host_fg, modifier: Modifier::empty() }, + TitleSegment { text: format!(" {} ", self.minion_logs_source_kind), bg: kind_bg, fg: kind_fg, modifier: Modifier::empty() }, ]; if self.minion_logs_polling { - segments.push(TitleSegment { text: " \u{27F3} ".into(), bg: poll_bg, fg: poll_fg }); + segments.push(TitleSegment { text: " \u{27F3} ".into(), bg: poll_bg, fg: poll_fg, modifier: Modifier::empty() }); } let min_width = title::ensure_inner_width(60, &title_style, &segments).saturating_add(2); let width = parent.width.saturating_sub(6).clamp(min_width, 140); @@ -155,12 +155,12 @@ impl SysInspectUX { let bg = palette::BG_2; let mut segments = vec![ - TitleSegment { text: " Master ".into(), bg: palette::PROCESSING_GLOW, fg: palette::FG }, - TitleSegment { text: format!(" {} ", section.title), bg: palette::PROCESSING_HEAT, fg: palette::SUCCESS }, - TitleSegment { text: format!(" {} ", section.path), bg: palette::PROCESSING_PEAK, fg: palette::FG }, + TitleSegment { text: " Master ".into(), bg: palette::PROCESSING_GLOW, fg: palette::FG, modifier: Modifier::empty() }, + TitleSegment { text: format!(" {} ", section.title), bg: palette::PROCESSING_HEAT, fg: palette::SUCCESS, modifier: Modifier::empty() }, + TitleSegment { text: format!(" {} ", section.path), bg: palette::PROCESSING_PEAK, fg: palette::FG, modifier: Modifier::empty() }, ]; if self.master_logs_polling { - segments.push(TitleSegment { text: " \u{27F3} ".into(), bg: palette::PROCESSING, fg: palette::BG_1 }); + segments.push(TitleSegment { text: " \u{27F3} ".into(), bg: palette::PROCESSING, fg: palette::BG_1, modifier: Modifier::empty() }); } let min_width = title::ensure_inner_width(60, &title_style, &segments).saturating_add(2); let width = parent.width.saturating_sub(6).clamp(min_width, 140); diff --git a/src/ui/repomanager.rs b/src/ui/repomanager.rs index 43d49830..3a2518de 100644 --- a/src/ui/repomanager.rs +++ b/src/ui/repomanager.rs @@ -283,7 +283,7 @@ impl RepoManager { let inner = block.inner(canvas); block.render(canvas, buf); - let tab_names = ["Modules", "Libraries", "Profiles"]; + let tab_names = ["Modules", "Libraries", "Profiles", "Platforms"]; let section_name = tab_names[self.active_tab as usize]; let title_style = TitleStyle::cyberpunk(palette::PROCESSING_GLOW); title::overlay_gradient_title( @@ -291,8 +291,8 @@ impl RepoManager { canvas, &title_style, &[ - TitleSegment { text: " Artefacts ".into(), bg: palette::PROCESSING_BASE, fg: palette::FG }, - TitleSegment { text: format!(" {section_name} "), bg: palette::PROCESSING_HEAT, fg: palette::FG }, + TitleSegment { text: " Artefacts ".into(), bg: palette::PROCESSING_BASE, fg: palette::FG, modifier: Modifier::empty() }, + TitleSegment { text: format!(" {section_name} "), bg: palette::PROCESSING_HEAT, fg: palette::FG, modifier: Modifier::empty() }, ], ); @@ -318,6 +318,7 @@ impl RepoManager { 0 => self.render_modules(body, buf), 1 => self.render_libraries(body, buf), 2 => self.profiles.render_list(body, buf, self.filter_focus, &self.filter), + 3 => self.render_platforms_placeholder(body, buf), _ => {} } Self::draw_shadow(buf, canvas, dlg_w, dlg_h); @@ -357,7 +358,12 @@ impl RepoManager { buf, canvas, &title_style, - &[TitleSegment { text: " Module and Library Manager ".into(), bg: palette::PROCESSING_BASE, fg: palette::FG }], + &[TitleSegment { + text: " Module and Library Manager ".into(), + bg: palette::PROCESSING_BASE, + fg: palette::FG, + modifier: Modifier::empty(), + }], ); if inner.height < 6 || self.staged.is_empty() { @@ -711,6 +717,13 @@ impl RepoManager { StatefulWidget::render(&inp, Rect::new(input_x, area.y, input_w, 1), buf, &mut is); } + fn render_platforms_placeholder(&self, inner: Rect, buf: &mut Buffer) { + let msg = "Platforms management is not implemented yet"; + let x = inner.x + (inner.width.saturating_sub(msg.len() as u16)) / 2; + let y = inner.y + inner.height / 2; + buf.set_string(x, y, msg, Style::default().fg(palette::MUTED)); + } + pub fn handle_info_key(&mut self, key: crossterm::event::KeyEvent) -> bool { if !self.info_visible { return false; @@ -794,7 +807,7 @@ impl RepoManager { buf, canvas, &title_style, - &[TitleSegment { text: format!(" {} ", row.name), bg: palette::PROCESSING_BASE, fg: palette::FG }], + &[TitleSegment { text: format!(" {} ", row.name), bg: palette::PROCESSING_BASE, fg: palette::FG, modifier: Modifier::empty() }], ); if inner.height < 4 { @@ -915,7 +928,7 @@ impl RepoManager { buf, canvas, &title_style, - &[TitleSegment { text: format!(" {} ", lib.name), bg: palette::PROCESSING_BASE, fg: palette::FG }], + &[TitleSegment { text: format!(" {} ", lib.name), bg: palette::PROCESSING_BASE, fg: palette::FG, modifier: Modifier::empty() }], ); let key_style = Style::default().fg(palette::PROCESSING).add_modifier(Modifier::BOLD); diff --git a/src/ui/setup.rs b/src/ui/setup.rs index dab414a3..265fd64d 100644 --- a/src/ui/setup.rs +++ b/src/ui/setup.rs @@ -298,7 +298,7 @@ impl MasterSetupWizard { buf, canvas, &title_style, - &[TitleSegment { text: " Master Setup ".into(), bg: palette::PROCESSING_BASE, fg: palette::FG }], + &[TitleSegment { text: " Master Setup ".into(), bg: palette::PROCESSING_BASE, fg: palette::FG, modifier: Modifier::empty() }], ); if inner.height < 3 { diff --git a/src/ui/title.rs b/src/ui/title.rs index 47cffdc1..830ba9eb 100644 --- a/src/ui/title.rs +++ b/src/ui/title.rs @@ -1,7 +1,7 @@ use ratatui::{ buffer::Buffer, layout::{Position, Rect}, - style::Color, + style::{Color, Modifier}, }; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; @@ -9,6 +9,7 @@ pub struct TitleSegment { pub text: String, pub bg: Color, pub fg: Color, + pub modifier: Modifier, } pub struct TitleStyle { @@ -78,7 +79,7 @@ pub fn overlay_gradient_title(buf: &mut Buffer, block_rect: Rect, style: &TitleS let available = x_end.saturating_sub(cx) as usize; let text = truncate_to_width(&seg.text, available); if !text.is_empty() { - cell_set_string_style(buf, cx, row_y, &text, seg.fg, seg.bg); + cell_set_string_style(buf, cx, row_y, &text, seg.fg, seg.bg, seg.modifier); cx += UnicodeWidthStr::width(text.as_str()) as u16; } } @@ -251,6 +252,6 @@ fn cell_set_symbol_style(buf: &mut Buffer, x: u16, y: u16, symbol: &str, fg: Col } } -fn cell_set_string_style(buf: &mut Buffer, x: u16, y: u16, text: &str, fg: Color, bg: Color) { - buf.set_string(x, y, text, ratatui::style::Style::default().fg(fg).bg(bg)); +fn cell_set_string_style(buf: &mut Buffer, x: u16, y: u16, text: &str, fg: Color, bg: Color, modifier: Modifier) { + buf.set_string(x, y, text, ratatui::style::Style::default().fg(fg).bg(bg).add_modifier(modifier)); } diff --git a/src/ui/traitsview.rs b/src/ui/traitsview.rs index 393118a2..2b8d894d 100644 --- a/src/ui/traitsview.rs +++ b/src/ui/traitsview.rs @@ -45,8 +45,8 @@ impl SysInspectUX { let title_style = TitleStyle::cyberpunk(palette::PROCESSING_GLOW); let title_segments = [ - TitleSegment { text: " Minion Traits: ".into(), bg: palette::PROCESSING_GLOW, fg: palette::FG }, - TitleSegment { text: format!(" {name} "), bg: palette::PROCESSING_HEAT, fg: palette::FG }, + TitleSegment { text: " Minion Traits: ".into(), bg: palette::PROCESSING_GLOW, fg: palette::FG, modifier: Modifier::empty() }, + TitleSegment { text: format!(" {name} "), bg: palette::PROCESSING_HEAT, fg: palette::FG, modifier: Modifier::empty() }, ]; let content_w = title::ensure_inner_width((line_w + 6) as u16, &title_style, &title_segments); let w = content_w.min(parent.width.saturating_sub(8)).max(40); From 02c9d372dbb36b98ba87f8088eddc1607a7bba56 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 13 Jun 2026 01:04:32 +0200 Subject: [PATCH 18/25] Add platforms support in repo manager --- src/ui/filepicker.rs | 42 +++++++--- src/ui/mod.rs | 120 ++++++++++++++++++++++++-- src/ui/platforms.rs | 191 ++++++++++++++++++++++++++++++++++++++++++ src/ui/repomanager.rs | 16 ++-- 4 files changed, 343 insertions(+), 26 deletions(-) create mode 100644 src/ui/platforms.rs diff --git a/src/ui/filepicker.rs b/src/ui/filepicker.rs index cbc0a1cb..3f3020c9 100644 --- a/src/ui/filepicker.rs +++ b/src/ui/filepicker.rs @@ -27,6 +27,7 @@ pub enum PickerMode { FilePicker, Any, LibrarySelector, + MinionBuild, } #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -193,6 +194,8 @@ impl FilePicker { if is_dir { dirs.push(de); + } else if self.mode == PickerMode::MinionBuild && !de.name.starts_with("sysminion") { + // MinionBuild mode only shows sysminion* files } else { files.push(de); } @@ -305,14 +308,21 @@ impl FilePicker { self.visible = false; } KeyCode::Tab => { - if (self.mode == PickerMode::FilePicker || self.mode == PickerMode::Any || self.mode == PickerMode::LibrarySelector) + if (self.mode == PickerMode::FilePicker + || self.mode == PickerMode::Any + || self.mode == PickerMode::LibrarySelector + || self.mode == PickerMode::MinionBuild) && self.entries.len() > self.dirs_end { self.focus = if self.focus == PickerFocus::Dirs { PickerFocus::Files } else { PickerFocus::Dirs }; } } KeyCode::BackTab => { - if self.mode == PickerMode::FilePicker || self.mode == PickerMode::Any || self.mode == PickerMode::LibrarySelector { + if self.mode == PickerMode::FilePicker + || self.mode == PickerMode::Any + || self.mode == PickerMode::LibrarySelector + || self.mode == PickerMode::MinionBuild + { self.focus = if self.focus == PickerFocus::Files { PickerFocus::Dirs } else { PickerFocus::Files }; } } @@ -387,11 +397,12 @@ impl FilePicker { PickerFocus::Files => self.dirs_end + self.file_cursor, }; if let Some(entry) = self.entries.get(idx) { - let selectable = if self.mode == PickerMode::Any || self.mode == PickerMode::LibrarySelector { - !entry.is_parent - } else { - !entry.is_parent && !entry.is_dir - }; + let selectable = + if self.mode == PickerMode::Any || self.mode == PickerMode::LibrarySelector || self.mode == PickerMode::MinionBuild { + !entry.is_parent + } else { + !entry.is_parent && !entry.is_dir + }; if selectable { self.selected = Some(entry.path.clone()); self.filter_input = InputState::new(); @@ -457,6 +468,7 @@ impl FilePicker { PickerMode::FilePicker => " File Selector ", PickerMode::Any => " Module Selector ", PickerMode::LibrarySelector => " Library Selector ", + PickerMode::MinionBuild => " SysMinion Selector ", }; let title_style = TitleStyle::cyberpunk(palette::PROCESSING_GLOW); @@ -496,8 +508,15 @@ impl FilePicker { let filter_line = filter_active as u16; row_y += 1; - let sections: u16 = - if self.mode == PickerMode::FilePicker || self.mode == PickerMode::Any || self.mode == PickerMode::LibrarySelector { 2 } else { 1 }; + let sections: u16 = if self.mode == PickerMode::FilePicker + || self.mode == PickerMode::Any + || self.mode == PickerMode::LibrarySelector + || self.mode == PickerMode::MinionBuild + { + 2 + } else { + 1 + }; let available = inner.height.saturating_sub(1).saturating_sub(row_y.saturating_sub(inner.y)).saturating_sub(filter_line); let dir_rows = if sections == 2 { available / 2 } else { available }; @@ -520,7 +539,10 @@ impl FilePicker { row_y = dir_area.y + dir_area.height; // ── Files section ── - if (self.mode == PickerMode::FilePicker || self.mode == PickerMode::Any || self.mode == PickerMode::LibrarySelector) + if (self.mode == PickerMode::FilePicker + || self.mode == PickerMode::Any + || self.mode == PickerMode::LibrarySelector + || self.mode == PickerMode::MinionBuild) && row_y + 1 < inner.y + inner.height { dashed_title( diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 1e2f56b4..df6880c0 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -47,6 +47,7 @@ mod filepicker; mod macts; mod online; mod palette; +mod platforms; mod profiles; mod rawlogs; mod repomanager; @@ -648,6 +649,7 @@ impl SysInspectUX { match self.repo_manager.active_tab { 0 => self.process_module_add(&path), 1 => self.process_library_add(&path), + 3 => self.process_platform_add(&path), _ => {} } } @@ -659,6 +661,9 @@ impl SysInspectUX { if self.repo_manager.needs_reload { self.repo_manager.needs_reload = false; let _ = self.load_module_index(); + if self.repo_manager.active_tab == 3 { + let _ = self.load_platforms(); + } } } else { // Track that a reload is needed when progress finishes @@ -1788,10 +1793,19 @@ impl SysInspectUX { let checked: Vec<_> = self.repo_manager.staged.iter().filter(|m| m.checked).map(|m| m.name.clone()).collect(); if checked.is_empty() { self.error_alert_visible = true; - self.error_alert_message = "No modules selected".to_string(); + self.error_alert_message = "No items selected".to_string(); } else { self.repo_manager.exit_staging(); - self.bulk_delete_modules(&checked); + match self.repo_manager.staging_mode { + repomanager::StagingMode::PlatformBuildAdd => { + for name in &checked { + self.do_platform_remove(name); + } + } + _ => { + self.bulk_delete_modules(&checked); + } + } } } if !self.repo_manager.staging @@ -1898,7 +1912,9 @@ impl SysInspectUX { return true; } } - let total_count = if self.repo_manager.active_tab == 2 { + let total_count = if self.repo_manager.active_tab == 3 { + self.repo_manager.platforms.filtered_count(self.repo_manager.filter.value()) + } else if self.repo_manager.active_tab == 2 { self.repo_manager.profiles.filtered_count(self.repo_manager.filter.value()) } else if self.repo_manager.active_tab == 1 { self.repo_filtered_lib_count() @@ -1906,7 +1922,9 @@ impl SysInspectUX { self.repo_filtered_count() }; let max_cursor = total_count.saturating_sub(1); - let cursor_ref: &mut usize = if self.repo_manager.active_tab == 2 { + let cursor_ref: &mut usize = if self.repo_manager.active_tab == 3 { + &mut self.repo_manager.platforms.cursor + } else if self.repo_manager.active_tab == 2 { &mut self.repo_manager.profiles.cursor } else if self.repo_manager.active_tab == 1 { &mut self.repo_manager.lib_cursor @@ -1925,29 +1943,39 @@ impl SysInspectUX { self.repo_manager.cursor = 0; self.repo_manager.lib_cursor = 0; self.repo_manager.profiles.cursor = 0; + self.repo_manager.platforms.cursor = 0; if self.repo_manager.active_tab == 1 { let _ = self.load_library_index(); } if self.repo_manager.active_tab == 2 { let _ = self.load_profile_list(); } + if self.repo_manager.active_tab == 3 { + let _ = self.load_platforms(); + } } KeyCode::Right => { self.repo_manager.active_tab = (self.repo_manager.active_tab + 1).min(3); self.repo_manager.cursor = 0; self.repo_manager.lib_cursor = 0; self.repo_manager.profiles.cursor = 0; + self.repo_manager.platforms.cursor = 0; if self.repo_manager.active_tab == 1 { let _ = self.load_library_index(); } if self.repo_manager.active_tab == 2 { let _ = self.load_profile_list(); } + if self.repo_manager.active_tab == 3 { + let _ = self.load_platforms(); + } } KeyCode::Up => { if self.repo_manager.active_tab == 2 { let fv = self.repo_manager.filter.value().to_string(); self.repo_manager.profiles.handle_list_key(e.code, &mut self.repo_manager.filter_focus, &fv); + } else if self.repo_manager.active_tab == 3 { + self.repo_manager.platforms.handle_list_key(e.code); } else { *cursor_ref = cursor_ref.saturating_sub(1); } @@ -1956,6 +1984,8 @@ impl SysInspectUX { if self.repo_manager.active_tab == 2 { let fv = self.repo_manager.filter.value().to_string(); self.repo_manager.profiles.handle_list_key(e.code, &mut self.repo_manager.filter_focus, &fv); + } else if self.repo_manager.active_tab == 3 { + self.repo_manager.platforms.handle_list_key(e.code); } else { *cursor_ref = (*cursor_ref + 1).min(max_cursor); } @@ -1964,6 +1994,8 @@ impl SysInspectUX { if self.repo_manager.active_tab == 2 { let fv = self.repo_manager.filter.value().to_string(); self.repo_manager.profiles.handle_list_key(e.code, &mut self.repo_manager.filter_focus, &fv); + } else if self.repo_manager.active_tab == 3 { + self.repo_manager.platforms.handle_list_key(e.code); } else { *cursor_ref = cursor_ref.saturating_sub(page); } @@ -1972,12 +2004,16 @@ impl SysInspectUX { if self.repo_manager.active_tab == 2 { let fv = self.repo_manager.filter.value().to_string(); self.repo_manager.profiles.handle_list_key(e.code, &mut self.repo_manager.filter_focus, &fv); + } else if self.repo_manager.active_tab == 3 { + self.repo_manager.platforms.handle_list_key(e.code); } else { *cursor_ref = (*cursor_ref + page).min(max_cursor); } } KeyCode::Enter => { - if self.repo_manager.active_tab == 2 { + if self.repo_manager.active_tab == 3 { + // Platforms have no detail view + } else if self.repo_manager.active_tab == 2 { let name = match self.repo_manager.profiles.selected_profile_name() { Some(n) => n.to_string(), None => return true, @@ -2011,7 +2047,22 @@ impl SysInspectUX { } } KeyCode::Delete => { - if self.repo_manager.active_tab == 2 { + if self.repo_manager.active_tab == 3 { + if let Some(name) = self.repo_manager.platforms.selected_name() { + self.repo_manager.delete_mode = true; + self.repo_manager.staged = vec![repomanager::StagedModule { + name, + version: None, + descr: String::new(), + path: std::path::PathBuf::new(), + checked: false, + }]; + self.repo_manager.staging_mode = repomanager::StagingMode::PlatformBuildAdd; + self.repo_manager.staging = true; + self.repo_manager.staging_cursor = 0; + self.repo_manager.staging_focus = repomanager::StagingFocus::List; + } + } else if self.repo_manager.active_tab == 2 { if let Some(name) = self.repo_manager.profiles.selected_profile_name() { self.repo_manager.profiles.open_delete(name.to_string()); self.status_at_profiles(); @@ -2036,7 +2087,9 @@ impl SysInspectUX { } } KeyCode::Insert | KeyCode::Char('i') if !e.modifiers.contains(KeyModifiers::CONTROL) => { - if self.repo_manager.active_tab == 2 { + if self.repo_manager.active_tab == 3 { + self.file_picker.open(&std::env::current_dir().unwrap_or_default(), filepicker::PickerMode::MinionBuild); + } else if self.repo_manager.active_tab == 2 { self.repo_manager.profiles.open_create(); self.status_at_profiles(); } else { @@ -2195,6 +2248,26 @@ impl SysInspectUX { } } + fn load_platforms(&mut self) -> Result<(), String> { + let repo_root = self.cfg.fileserver_root().join("repo"); + let repo = SysInspectModPak::new(repo_root).map_err(|e| format!("Cannot open repository: {e}"))?; + let builds = repo.minion_builds(); + self.repo_manager.platforms.rows = builds + .into_iter() + .map(|r| { + let chk = r.checksum().to_string(); + platforms::PlatformRow { + platform: r.platform().to_string(), + arch: r.arch().to_string(), + version: r.version().to_string(), + checksum: if chk.len() > 12 { format!("{}...{}", &chk[..4], &chk[chk.len() - 4..]) } else { chk }, + } + }) + .collect(); + self.repo_manager.platforms.cursor = 0; + Ok(()) + } + fn load_library_index(&mut self) -> Result<(), String> { let resp = tokio::task::block_in_place(|| { tokio::runtime::Handle::current() @@ -2394,6 +2467,39 @@ impl SysInspectUX { } } + fn process_platform_add(&mut self, path: &std::path::Path) { + let repo_root = self.cfg.fileserver_root().join("repo"); + match SysInspectModPak::new(repo_root) { + Ok(mut repo) => { + if let Err(e) = repo.add_minion_build(path.to_path_buf()) { + self.error_alert_visible = true; + self.error_alert_message = format!("Cannot add minion build: {e}"); + } else { + let _ = self.load_platforms(); + } + } + Err(e) => { + self.error_alert_visible = true; + self.error_alert_message = format!("Cannot open repository: {e}"); + } + } + } + + fn do_platform_remove(&mut self, name: &str) { + let repo_root = self.cfg.fileserver_root().join("repo"); + match SysInspectModPak::new(repo_root) { + Ok(mut repo) => { + let _ = repo.remove_minion_build(vec![name.to_string()]); + } + Err(e) => { + self.error_alert_visible = true; + self.error_alert_message = format!("Cannot open repository: {e}"); + return; + } + } + let _ = self.load_platforms(); + } + fn read_spec_version_descr(spec: &std::path::Path) -> (Option, String) { match std::fs::read_to_string(spec) { Ok(yaml) => match serde_yaml::from_str::(&yaml) { diff --git a/src/ui/platforms.rs b/src/ui/platforms.rs new file mode 100644 index 00000000..3a09a24a --- /dev/null +++ b/src/ui/platforms.rs @@ -0,0 +1,191 @@ +use super::palette; +use crossterm::event::KeyCode; +use ratatui::widgets::StatefulWidget; +use ratatui::{ + layout::Position, + prelude::{Buffer, Rect}, + style::{Modifier, Style}, +}; +use ratatui_cheese::input::{Input, InputState, InputStyles}; +use std::cell::Cell; + +#[derive(Debug)] +pub struct PlatformRow { + pub platform: String, + pub arch: String, + pub version: String, + pub checksum: String, +} + +#[derive(Debug)] +pub struct PlatformsManager { + pub rows: Vec, + pub cursor: usize, + pub scroll: Cell, +} + +impl Default for PlatformsManager { + fn default() -> Self { + Self { rows: Vec::new(), cursor: 0, scroll: Cell::new(0) } + } +} + +impl PlatformsManager { + pub fn handle_list_key(&mut self, key: KeyCode) -> bool { + let page = 10usize; + let max = self.rows.len().saturating_sub(1); + match key { + KeyCode::Up => self.cursor = self.cursor.saturating_sub(1), + KeyCode::Down => self.cursor = (self.cursor + 1).min(max), + KeyCode::PageUp => self.cursor = self.cursor.saturating_sub(page), + KeyCode::PageDown => self.cursor = (self.cursor + page).min(max), + _ => return false, + } + true + } + + pub fn filtered_count(&self, filter_value: &str) -> usize { + let f = filter_value.to_lowercase(); + if f.is_empty() { + return self.rows.len(); + } + self.rows + .iter() + .filter(|r| r.platform.to_lowercase().contains(&f) || r.arch.to_lowercase().contains(&f) || r.version.to_lowercase().contains(&f)) + .count() + } + + pub fn selected_name(&self) -> Option { + self.rows.get(self.cursor).map(|r| format!("{}/{}", r.platform, r.arch)) + } + + pub fn render_list(&self, inner: Rect, buf: &mut Buffer, filter_focus: bool, filter_state: &InputState) { + if inner.height < 2 { + return; + } + + let [filter_area, list_area] = ratatui::layout::Layout::default() + .direction(ratatui::layout::Direction::Vertical) + .constraints([ratatui::layout::Constraint::Length(1), ratatui::layout::Constraint::Min(0)]) + .split(inner) + .as_ref() + .try_into() + .unwrap(); + + Self::render_filter_row(filter_area, buf, filter_focus, filter_state); + + if self.rows.is_empty() { + let msg = "(no platform builds found)"; + let x = list_area.x + (list_area.width.saturating_sub(msg.len() as u16)) / 2; + let y = list_area.y + list_area.height / 2; + buf.set_string(x, y, msg, Style::default().fg(palette::MUTED)); + return; + } + + let flt = filter_state.value().to_lowercase(); + let filtered: Vec<(usize, &PlatformRow)> = self + .rows + .iter() + .enumerate() + .filter(|(_, r)| { + flt.is_empty() + || r.platform.to_lowercase().contains(&flt) + || r.arch.to_lowercase().contains(&flt) + || r.version.to_lowercase().contains(&flt) + }) + .collect(); + + let view_h = list_area.height as usize; + let total = filtered.len(); + let max_scroll = total.saturating_sub(view_h); + let mut s = self.scroll.get(); + let cursor = self.cursor.min(total.saturating_sub(1)); + if cursor < s { + s = cursor; + } + if cursor >= s + view_h { + s = cursor.saturating_sub(view_h.saturating_sub(1)); + } + s = s.min(max_scroll); + self.scroll.set(s); + + if total == 0 { + let msg = "(no matches)"; + let x = list_area.x + (list_area.width.saturating_sub(msg.len() as u16)) / 2; + let y = list_area.y + list_area.height / 2; + buf.set_string(x, y, msg, Style::default().fg(palette::MUTED)); + return; + } + + let hl_style = Style::default().fg(palette::HIGHLIGHT).add_modifier(Modifier::BOLD); + let plat_w: u16 = 12; + let arch_w: u16 = 10; + let ver_w: u16 = 10; + let sum_w = list_area.width.saturating_sub(plat_w + arch_w + ver_w + 12); + + for i in 0..view_h.min(total.saturating_sub(s)) { + let fi = s + i; + let (_oi, row) = filtered[fi]; + let ry = list_area.y + i as u16; + let sel = !filter_focus && fi == cursor; + let row_style = if sel { hl_style } else { Style::default().fg(palette::FG) }; + let prefix = if sel { " ✨ " } else { " " }; + buf.set_string(list_area.x + 1, ry, prefix, row_style); + buf.set_string(list_area.x + 5, ry, truncate_str(&row.platform, plat_w as usize), row_style); + let arch_style = if sel { row_style } else { Style::default().fg(palette::PROCESSING) }; + buf.set_string(list_area.x + 5 + plat_w + 1, ry, truncate_str(&row.arch, arch_w as usize), arch_style); + let ver_style = if sel { row_style } else { Style::default().fg(palette::HIGHLIGHT) }; + buf.set_string(list_area.x + 5 + plat_w + 1 + arch_w + 1, ry, truncate_str(&row.version, ver_w as usize), ver_style); + let sum_style = if sel { row_style } else { Style::default().fg(palette::GRAY_1) }; + let sum_x = list_area.x + 5 + plat_w + 1 + arch_w + 1 + ver_w + 1; + buf.set_string(sum_x, ry, truncate_str(&row.checksum, sum_w as usize), sum_style); + } + + if total > view_h { + let bh = ((view_h as f64 / total as f64) * view_h as f64).max(1.0) as usize; + let by = ((s as f64 / total as f64) * (view_h - bh) as f64) as usize; + for i in 0..view_h { + let sx = list_area.right().saturating_sub(1); + let sy = list_area.y + i as u16; + if i >= by && i < by + bh { + buf.set_string(sx, sy, "█", Style::default().fg(palette::PROCESSING_HEAT)); + } else { + buf.set_string(sx, sy, "│", Style::default().fg(palette::MUTED)); + } + } + } + } + + fn render_filter_row(area: Rect, buf: &mut Buffer, focused: bool, filter_state: &InputState) { + let label_style = if focused { Style::default().fg(palette::ACCENT) } else { Style::default().fg(palette::MUTED) }; + buf.set_string(area.x + 2, area.y, "filter: ", label_style); + + let input_x = area.x + 10; + let input_w = area.width.saturating_sub(10); + if input_w == 0 { + return; + } + + let field_bg = if focused { palette::HIGHLIGHT } else { palette::GRAY_1 }; + for cx in input_x..input_x + input_w { + if let Some(cell) = buf.cell_mut(Position::new(cx, area.y)) { + cell.set_bg(field_bg); + } + } + + let mut is = InputState::new(); + is.set_value(filter_state.value().to_string()); + is.set_focused(focused); + let fc = filter_state.cursor_pos(); + while is.cursor_pos() < fc { + is.move_right(); + } + let styles = InputStyles { text: Style::default().fg(palette::BG_1), ..Default::default() }; + let inp = Input::new("").prompt("").placeholder("search platforms...").styles(styles); + StatefulWidget::render(&inp, Rect::new(input_x, area.y, input_w, 1), buf, &mut is); + } +} + +fn truncate_str(s: &str, max_w: usize) -> String { + if s.len() <= max_w { s.to_string() } else { format!("{}…", &s[..max_w.saturating_sub(1)]) } +} diff --git a/src/ui/repomanager.rs b/src/ui/repomanager.rs index 3a2518de..8e3961fb 100644 --- a/src/ui/repomanager.rs +++ b/src/ui/repomanager.rs @@ -1,5 +1,5 @@ use super::{ - dslbrowser, palette, profiles, + dslbrowser, palette, platforms, profiles, title::{self, TitleSegment, TitleStyle}, }; use libsysinspect::console::{ConsoleModuleArgument, ConsoleModuleRow}; @@ -40,6 +40,7 @@ pub enum StagingMode { ModuleDelete, ProfileModuleAdd, ProfileLibraryAdd, + PlatformBuildAdd, } #[derive(Debug)] @@ -85,6 +86,9 @@ pub struct RepoManager { // Profiles pub profiles: profiles::ProfilesManager, + + // Platforms + pub platforms: platforms::PlatformsManager, } impl Default for RepoManager { @@ -117,6 +121,7 @@ impl Default for RepoManager { lib_cursor: 0, lib_scroll: Cell::new(0), profiles: profiles::ProfilesManager::default(), + platforms: platforms::PlatformsManager::default(), } } } @@ -318,7 +323,7 @@ impl RepoManager { 0 => self.render_modules(body, buf), 1 => self.render_libraries(body, buf), 2 => self.profiles.render_list(body, buf, self.filter_focus, &self.filter), - 3 => self.render_platforms_placeholder(body, buf), + 3 => self.platforms.render_list(body, buf, self.filter_focus, &self.filter), _ => {} } Self::draw_shadow(buf, canvas, dlg_w, dlg_h); @@ -717,13 +722,6 @@ impl RepoManager { StatefulWidget::render(&inp, Rect::new(input_x, area.y, input_w, 1), buf, &mut is); } - fn render_platforms_placeholder(&self, inner: Rect, buf: &mut Buffer) { - let msg = "Platforms management is not implemented yet"; - let x = inner.x + (inner.width.saturating_sub(msg.len() as u16)) / 2; - let y = inner.y + inner.height / 2; - buf.set_string(x, y, msg, Style::default().fg(palette::MUTED)); - } - pub fn handle_info_key(&mut self, key: crossterm::event::KeyEvent) -> bool { if !self.info_visible { return false; From e8448fb2d65205575747fc7c009b6d3f623ecee3 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 13 Jun 2026 13:42:27 +0200 Subject: [PATCH 19/25] Fix musl build, causing SIGSEGV on release optimised --- modules/build-help.rs | 1 - scripts/run-musl-cargo.sh | 11 ++++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/modules/build-help.rs b/modules/build-help.rs index e825db19..03de79cf 100644 --- a/modules/build-help.rs +++ b/modules/build-help.rs @@ -51,5 +51,4 @@ pub fn generate_help() { let path = format!("{out_dir}/help.txt"); fs::File::create(&path).unwrap().write_all(&help).unwrap(); - println!("cargo:rustc-link-lib=c"); } diff --git a/scripts/run-musl-cargo.sh b/scripts/run-musl-cargo.sh index 5476f604..30c6c613 100644 --- a/scripts/run-musl-cargo.sh +++ b/scripts/run-musl-cargo.sh @@ -25,6 +25,15 @@ fi export LIBRARY_PATH="$libdir${LIBRARY_PATH:+:$LIBRARY_PATH}" export C_INCLUDE_PATH="$includedir${C_INCLUDE_PATH:+:$C_INCLUDE_PATH}" export CPLUS_INCLUDE_PATH="$includedir${CPLUS_INCLUDE_PATH:+:$CPLUS_INCLUDE_PATH}" -export RUSTFLAGS="${RUSTFLAGS:+$RUSTFLAGS }-Lnative=$libdir" +export RUSTFLAGS="${RUSTFLAGS:+$RUSTFLAGS }-Lnative=$libdir -C target-feature=+crt-static -C relocation-model=static -C link-arg=-static -C link-arg=-no-pie -C link-arg=-lc" + +case " $* " in + *" --release "*) + # Keep the workspace's aggressive release profile for native builds, but + # tone it down for musl until the release-startup crash is isolated. + export CARGO_PROFILE_RELEASE_STRIP=none + export CARGO_PROFILE_RELEASE_LTO=false + ;; +esac exec cargo "$@" From 0169ca3873b9341e9c66140ffba03a40a554ec31 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 13 Jun 2026 14:40:11 +0200 Subject: [PATCH 20/25] Add supported minion platform label helper and change the static ELF policies for non-Linux OS --- libmodpak/src/lib.rs | 23 ++++++++++++++++++++--- libmodpak/src/lib_ut.rs | 8 ++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/libmodpak/src/lib.rs b/libmodpak/src/lib.rs index aeabd4f8..896d3019 100644 --- a/libmodpak/src/lib.rs +++ b/libmodpak/src/lib.rs @@ -815,7 +815,21 @@ impl SysInspectModPak { Ok(()) } - /// Add one statically linked sysminion build to the repository. + fn requires_static_minion(os: &str) -> bool { + matches!(os, "linux") + } + + fn minion_platform_label(os: &str) -> &str { + match os { + "linux" => "Linux", + "freebsd" => "FreeBSD", + "netbsd" => "NetBSD", + "openbsd" => "OpenBSD", + _ => os, + } + } + + /// Add one sysminion build to the repository. pub fn add_minion_build(&mut self, p: PathBuf) -> Result<(), SysinspectError> { let path = fs::canonicalize(p)?; let buff = fs::read(&path)?; @@ -823,8 +837,11 @@ impl SysInspectModPak { if !buff.starts_with(b"\x7FELF") { return Err(SysinspectError::MasterGeneralError("Minion build must be an ELF executable".to_string())); } - if !Self::is_static_elf(&buff)? { - return Err(SysinspectError::MasterGeneralError("Minion build must be a static ELF".to_string())); + if Self::requires_static_minion(os) && !Self::is_static_elf(&buff)? { + return Err(SysinspectError::MasterGeneralError(format!( + "{} minion build must be a static ELF", + Self::minion_platform_label(os) + ))); } let version = Self::get_minion_version(&buff) .ok_or_else(|| SysinspectError::MasterGeneralError("Minion build must be a sysminion executable".to_string()))?; diff --git a/libmodpak/src/lib_ut.rs b/libmodpak/src/lib_ut.rs index 5a9a1d17..c255830c 100644 --- a/libmodpak/src/lib_ut.rs +++ b/libmodpak/src/lib_ut.rs @@ -279,6 +279,14 @@ mod tests { assert!(repo.add_minion_build(file).is_err()); } + #[test] + fn minion_static_requirement_is_linux_only() { + assert!(SysInspectModPak::requires_static_minion("linux")); + assert!(!SysInspectModPak::requires_static_minion("freebsd")); + assert!(!SysInspectModPak::requires_static_minion("netbsd")); + assert!(!SysInspectModPak::requires_static_minion("openbsd")); + } + #[test] fn add_minion_build_rejects_non_sysminion_static_elf() { let Some(src) = std::env::current_dir().ok().map(|p| p.join("target/x86_64-unknown-linux-musl/debug/sysinspect")).filter(|p| p.exists()) From de76ce22d74e78f203e280f1cf3825c677d51c5b Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 13 Jun 2026 14:40:31 +0200 Subject: [PATCH 21/25] Update artefact manager UI --- src/ui/mod.rs | 74 ++++++++++++------- src/ui/platforms.rs | 165 ++++++++++++++++++++++++++++++++++++++---- src/ui/profiles.rs | 49 +++++++------ src/ui/repomanager.rs | 20 +++-- 4 files changed, 242 insertions(+), 66 deletions(-) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index df6880c0..2a35f78c 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1251,6 +1251,7 @@ impl SysInspectUX { || self.repo_manager.profiles.detail_visible || self.repo_manager.profiles.create_visible || self.repo_manager.profiles.delete_visible + || self.repo_manager.platforms.delete_visible } fn sync_main_focus_for_overlays(&mut self) { @@ -1796,16 +1797,7 @@ impl SysInspectUX { self.error_alert_message = "No items selected".to_string(); } else { self.repo_manager.exit_staging(); - match self.repo_manager.staging_mode { - repomanager::StagingMode::PlatformBuildAdd => { - for name in &checked { - self.do_platform_remove(name); - } - } - _ => { - self.bulk_delete_modules(&checked); - } - } + self.bulk_delete_modules(&checked); } } if !self.repo_manager.staging @@ -1912,6 +1904,18 @@ impl SysInspectUX { return true; } } + // Platform delete overlay (tab 3) + if self.repo_manager.active_tab == 3 && self.repo_manager.platforms.delete_visible { + let handled = self.repo_manager.platforms.handle_delete_key(e.code); + if !handled && e.code == KeyCode::Enter { + if self.repo_manager.platforms.delete_focus == platforms::DeleteFocus::YesBtn { + let name = self.repo_manager.platforms.delete_name.clone(); + self.do_platform_remove(&name); + } + self.repo_manager.platforms.delete_visible = false; + } + return true; + } let total_count = if self.repo_manager.active_tab == 3 { self.repo_manager.platforms.filtered_count(self.repo_manager.filter.value()) } else if self.repo_manager.active_tab == 2 { @@ -2049,24 +2053,30 @@ impl SysInspectUX { KeyCode::Delete => { if self.repo_manager.active_tab == 3 { if let Some(name) = self.repo_manager.platforms.selected_name() { - self.repo_manager.delete_mode = true; - self.repo_manager.staged = vec![repomanager::StagedModule { - name, - version: None, - descr: String::new(), - path: std::path::PathBuf::new(), - checked: false, - }]; - self.repo_manager.staging_mode = repomanager::StagingMode::PlatformBuildAdd; - self.repo_manager.staging = true; - self.repo_manager.staging_cursor = 0; - self.repo_manager.staging_focus = repomanager::StagingFocus::List; + self.repo_manager.platforms.open_delete(name); } } else if self.repo_manager.active_tab == 2 { if let Some(name) = self.repo_manager.profiles.selected_profile_name() { self.repo_manager.profiles.open_delete(name.to_string()); self.status_at_profiles(); } + } else if self.repo_manager.active_tab == 1 && !self.repo_manager.lib_rows.is_empty() { + self.repo_manager.delete_mode = true; + self.repo_manager.staged = self + .repo_manager + .lib_rows + .iter() + .map(|r| repomanager::StagedModule { + name: r.name.clone(), + version: Some(r.kind.clone()), + descr: r.checksum.clone(), + path: std::path::PathBuf::new(), + checked: false, + }) + .collect(); + self.repo_manager.staging = true; + self.repo_manager.staging_cursor = 0; + self.repo_manager.staging_focus = repomanager::StagingFocus::List; } else if !self.repo_manager.rows.is_empty() { self.repo_manager.delete_mode = true; self.repo_manager.staged = self @@ -2248,6 +2258,17 @@ impl SysInspectUX { } } + fn format_size(bytes: u64) -> String { + const UNITS: &[&str] = &["B", "KiB", "MiB", "GiB"]; + let mut size = bytes as f64; + let mut unit = 0; + while size >= 1024.0 && unit < UNITS.len() - 1 { + size /= 1024.0; + unit += 1; + } + format!("{size:.1} {}", UNITS[unit]) + } + fn load_platforms(&mut self) -> Result<(), String> { let repo_root = self.cfg.fileserver_root().join("repo"); let repo = SysInspectModPak::new(repo_root).map_err(|e| format!("Cannot open repository: {e}"))?; @@ -2256,11 +2277,13 @@ impl SysInspectUX { .into_iter() .map(|r| { let chk = r.checksum().to_string(); + let size_str = std::fs::metadata(r.path()).ok().map(|m| Self::format_size(m.len())).unwrap_or_default(); platforms::PlatformRow { platform: r.platform().to_string(), arch: r.arch().to_string(), version: r.version().to_string(), - checksum: if chk.len() > 12 { format!("{}...{}", &chk[..4], &chk[chk.len() - 4..]) } else { chk }, + size: size_str, + checksum: if chk.len() > 12 { format!("{}…{}", &chk[..4], &chk[chk.len() - 4..]) } else { chk }, } }) .collect(); @@ -2474,8 +2497,9 @@ impl SysInspectUX { if let Err(e) = repo.add_minion_build(path.to_path_buf()) { self.error_alert_visible = true; self.error_alert_message = format!("Cannot add minion build: {e}"); - } else { - let _ = self.load_platforms(); + } else if let Err(e) = self.load_platforms() { + self.error_alert_visible = true; + self.error_alert_message = format!("Failed to reload platforms: {e}"); } } Err(e) => { diff --git a/src/ui/platforms.rs b/src/ui/platforms.rs index 3a09a24a..650f3e32 100644 --- a/src/ui/platforms.rs +++ b/src/ui/platforms.rs @@ -1,12 +1,16 @@ use super::palette; +use super::title::{self, TitleSegment, TitleStyle}; use crossterm::event::KeyCode; +use libsysinspect::traits::os_display_name; use ratatui::widgets::StatefulWidget; use ratatui::{ layout::Position, prelude::{Buffer, Rect}, - style::{Modifier, Style}, + style::{Color, Modifier, Style}, + widgets::{Block, BorderType, Borders, Clear, Widget}, }; use ratatui_cheese::input::{Input, InputState, InputStyles}; +use ratatui_glamour::color::blend_2d; use std::cell::Cell; #[derive(Debug)] @@ -14,6 +18,7 @@ pub struct PlatformRow { pub platform: String, pub arch: String, pub version: String, + pub size: String, pub checksum: String, } @@ -22,11 +27,21 @@ pub struct PlatformsManager { pub rows: Vec, pub cursor: usize, pub scroll: Cell, + + pub delete_visible: bool, + pub delete_name: String, + pub delete_focus: DeleteFocus, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DeleteFocus { + YesBtn, + NoBtn, } impl Default for PlatformsManager { fn default() -> Self { - Self { rows: Vec::new(), cursor: 0, scroll: Cell::new(0) } + Self { rows: Vec::new(), cursor: 0, scroll: Cell::new(0), delete_visible: false, delete_name: String::new(), delete_focus: DeleteFocus::YesBtn } } } @@ -59,6 +74,116 @@ impl PlatformsManager { self.rows.get(self.cursor).map(|r| format!("{}/{}", r.platform, r.arch)) } + pub fn open_delete(&mut self, name: String) { + self.delete_name = name; + self.delete_focus = DeleteFocus::YesBtn; + self.delete_visible = true; + } + + pub fn handle_delete_key(&mut self, key: KeyCode) -> bool { + match key { + KeyCode::Esc => { + self.delete_visible = false; + } + KeyCode::Tab | KeyCode::BackTab => { + self.delete_focus = match self.delete_focus { + DeleteFocus::YesBtn => DeleteFocus::NoBtn, + DeleteFocus::NoBtn => DeleteFocus::YesBtn, + }; + } + KeyCode::Enter => return false, + _ => {} + } + true + } + + pub fn render_delete(&self, parent: Rect, buf: &mut Buffer) { + let w = (parent.width / 2).clamp(40, 60); + let h: u16 = 6; + let x = parent.x + (parent.width.saturating_sub(w)) / 2; + let y = parent.y + (parent.height.saturating_sub(h)) / 2; + let canvas = Rect { x, y, width: w, height: h }; + + Clear.render(canvas, buf); + + let grad = blend_2d(canvas.width as usize, canvas.height as usize, 10.0, &[palette::BG_1, palette::BG_0] as &[Color]); + for ry in 0..canvas.height { + for cx in 0..canvas.width { + let idx = ry as usize * canvas.width as usize + cx as usize; + if let Some(cell) = buf.cell_mut(Position::new(canvas.x + cx, canvas.y + ry)) { + cell.set_bg(grad[idx]); + } + } + } + + let block = Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(palette::PROCESSING_GLOW)) + .style(Style::default()); + let inner = block.inner(canvas); + block.render(canvas, buf); + + let title_style = TitleStyle::cyberpunk(palette::PROCESSING_GLOW); + title::overlay_gradient_title( + buf, + canvas, + &title_style, + &[TitleSegment { text: format!(" Delete {} ", self.delete_name), bg: palette::ERROR_BASE, fg: palette::FG, modifier: Modifier::empty() }], + ); + + let msg = format!("Delete platform \"{}\"?", self.delete_name); + let x = inner.x + (inner.width.saturating_sub(msg.len() as u16)) / 2; + buf.set_string(x, inner.y + 1, &msg, Style::default().fg(palette::FG)); + + let btn_y = inner.y + 3; + let yes_lbl = "[ Yes ]"; + let no_lbl = "[ No ]"; + let yes_w: u16 = 10; + let no_w: u16 = 10; + let gap: u16 = 3; + let total_btn_w = yes_w + gap + no_w; + let btn_x = inner.x + (inner.width.saturating_sub(total_btn_w)) / 2; + + let sel_btn = Style::default().fg(palette::WHITE).bg(palette::PROCESSING_HEAT).add_modifier(Modifier::BOLD); + let unsel_btn = Style::default().fg(palette::FG).bg(palette::BG_2).add_modifier(Modifier::BOLD); + + let yes_style = if self.delete_focus == DeleteFocus::YesBtn { sel_btn } else { unsel_btn }; + let no_style = if self.delete_focus == DeleteFocus::NoBtn { sel_btn } else { unsel_btn }; + buf.set_string(btn_x, btn_y, yes_lbl, yes_style); + buf.set_string(btn_x + yes_w + gap, btn_y, no_lbl, no_style); + + Self::draw_shadow(buf, canvas, w, h); + } + + fn draw_shadow(buf: &mut Buffer, canvas: Rect, dlg_w: u16, dlg_h: u16) { + let buf_area = buf.area(); + let x = canvas.x; + let y = canvas.y; + let max_x = buf_area.right().saturating_sub(1); + let max_y = buf_area.bottom().saturating_sub(1); + for idx in 0..dlg_w { + let sx = x.saturating_add(2).saturating_add(idx); + let sy = y.saturating_add(dlg_h); + if sx > max_x || sy > max_y { continue; } + if let Some(cell) = buf.cell_mut(Position::new(sx, sy)) { + cell.set_bg(palette::SHADOW_BG); + cell.set_fg(palette::SHADOW_FG); + } + } + for offset in 0..2u16 { + for idx in 0..dlg_h { + let sx = x.saturating_add(dlg_w).saturating_add(offset); + let sy = y.saturating_add(idx).saturating_add(1); + if sx > max_x || sy > max_y { continue; } + if let Some(cell) = buf.cell_mut(Position::new(sx, sy)) { + cell.set_bg(palette::SHADOW_BG); + cell.set_fg(palette::SHADOW_FG); + } + } + } + } + pub fn render_list(&self, inner: Rect, buf: &mut Buffer, filter_focus: bool, filter_state: &InputState) { if inner.height < 2 { return; @@ -117,28 +242,33 @@ impl PlatformsManager { return; } - let hl_style = Style::default().fg(palette::HIGHLIGHT).add_modifier(Modifier::BOLD); + let hl = Style::default().fg(palette::BLACK).bg(palette::HIGHLIGHT); let plat_w: u16 = 12; let arch_w: u16 = 10; let ver_w: u16 = 10; - let sum_w = list_area.width.saturating_sub(plat_w + arch_w + ver_w + 12); + let size_w: u16 = 10; + let sum_w = list_area.width.saturating_sub(plat_w + arch_w + ver_w + size_w + 16); for i in 0..view_h.min(total.saturating_sub(s)) { let fi = s + i; let (_oi, row) = filtered[fi]; let ry = list_area.y + i as u16; let sel = !filter_focus && fi == cursor; - let row_style = if sel { hl_style } else { Style::default().fg(palette::FG) }; - let prefix = if sel { " ✨ " } else { " " }; - buf.set_string(list_area.x + 1, ry, prefix, row_style); - buf.set_string(list_area.x + 5, ry, truncate_str(&row.platform, plat_w as usize), row_style); - let arch_style = if sel { row_style } else { Style::default().fg(palette::PROCESSING) }; - buf.set_string(list_area.x + 5 + plat_w + 1, ry, truncate_str(&row.arch, arch_w as usize), arch_style); - let ver_style = if sel { row_style } else { Style::default().fg(palette::HIGHLIGHT) }; - buf.set_string(list_area.x + 5 + plat_w + 1 + arch_w + 1, ry, truncate_str(&row.version, ver_w as usize), ver_style); - let sum_style = if sel { row_style } else { Style::default().fg(palette::GRAY_1) }; - let sum_x = list_area.x + 5 + plat_w + 1 + arch_w + 1 + ver_w + 1; - buf.set_string(sum_x, ry, truncate_str(&row.checksum, sum_w as usize), sum_style); + let display_platform = platform_label(&row.platform); + let row_style = if sel { hl } else { Style::default().fg(palette::FG) }; + if sel { + for cx in 0..list_area.width { + if let Some(cell) = buf.cell_mut(Position::new(list_area.x + cx, ry)) { + cell.set_bg(palette::HIGHLIGHT); + } + } + } + buf.set_string(list_area.x + 1, ry, &format!(" {}", truncate_str(&display_platform, plat_w as usize)), row_style); + buf.set_string(list_area.x + 1 + plat_w + 1, ry, &format!(" {}", truncate_str(&row.arch, arch_w as usize)), row_style); + buf.set_string(list_area.x + 1 + plat_w + 1 + arch_w + 1, ry, &format!(" {}", truncate_str(&row.version, ver_w as usize)), row_style); + buf.set_string(list_area.x + 1 + plat_w + 1 + arch_w + 1 + ver_w + 1, ry, &format!(" {}", truncate_str(&row.size, size_w as usize)), row_style); + let sum_x = list_area.x + 1 + plat_w + 1 + arch_w + 1 + ver_w + 1 + size_w + 1; + buf.set_string(sum_x, ry, &format!(" {}", truncate_str(&row.checksum, sum_w as usize)), row_style); } if total > view_h { @@ -186,6 +316,11 @@ impl PlatformsManager { } } +fn platform_label(raw: &str) -> String { + let normalized = raw.to_lowercase(); + os_display_name(&normalized).to_string() +} + fn truncate_str(s: &str, max_w: usize) -> String { if s.len() <= max_w { s.to_string() } else { format!("{}…", &s[..max_w.saturating_sub(1)]) } } diff --git a/src/ui/profiles.rs b/src/ui/profiles.rs index ceb2eeaf..5485ab5f 100644 --- a/src/ui/profiles.rs +++ b/src/ui/profiles.rs @@ -411,16 +411,21 @@ impl ProfilesManager { return; } - let hl_style = Style::default().fg(palette::HIGHLIGHT).add_modifier(Modifier::BOLD); + let hl = Style::default().fg(palette::BLACK).bg(palette::HIGHLIGHT); for i in 0..view_h.min(total.saturating_sub(s)) { let fi = s + i; let (_oi, name) = filtered[fi]; let ry = list_area.y + i as u16; let sel = !filter_focus && fi == cursor; - let row_style = if sel { hl_style } else { Style::default().fg(palette::FG) }; - let prefix = if sel { " ✨ " } else { " " }; - let line = format!("{prefix}{name}"); - buf.set_string(list_area.x + 1, ry, &line, row_style); + let row_style = if sel { hl } else { Style::default().fg(palette::FG) }; + if sel { + for cx in 0..list_area.width { + if let Some(cell) = buf.cell_mut(Position::new(list_area.x + cx, ry)) { + cell.set_bg(palette::HIGHLIGHT); + } + } + } + buf.set_string(list_area.x + 1, ry, &format!(" {}", name), row_style); } if total > view_h { @@ -695,20 +700,21 @@ impl ProfilesManager { // Buttons let btn_y = inner.y + 3; - let create_lbl = "[ Create ]"; - let cancel_lbl = "[ Cancel ]"; - let create_w = create_lbl.len() as u16; - let cancel_w = cancel_lbl.len() as u16; - let total_btn_w = create_w + cancel_w + 2; + let create_lbl = "[ Create ]"; + let cancel_lbl = "[ Cancel ]"; + let create_w: u16 = 10; + let cancel_w: u16 = 10; + let gap: u16 = 3; + let total_btn_w = create_w + gap + cancel_w; let btn_x = inner.x + (inner.width.saturating_sub(total_btn_w)) / 2; - let sel_btn = Style::default().fg(palette::WHITE).bg(palette::PROCESSING_HEAT); - let unsel_btn = Style::default().fg(palette::FG).bg(palette::BG_2); + let sel_btn = Style::default().fg(palette::WHITE).bg(palette::PROCESSING_HEAT).add_modifier(Modifier::BOLD); + let unsel_btn = Style::default().fg(palette::FG).bg(palette::BG_2).add_modifier(Modifier::BOLD); let create_style = if self.create_focus == ProfCreateFocus::CreateBtn { sel_btn } else { unsel_btn }; let cancel_style = if self.create_focus == ProfCreateFocus::CancelBtn { sel_btn } else { unsel_btn }; buf.set_string(btn_x, btn_y, create_lbl, create_style); - buf.set_string(btn_x + create_w + 2, btn_y, cancel_lbl, cancel_style); + buf.set_string(btn_x + create_w + gap, btn_y, cancel_lbl, cancel_style); Self::draw_shadow(buf, canvas, w, h); } @@ -755,20 +761,21 @@ impl ProfilesManager { // Buttons let btn_y = inner.y + 3; - let yes_lbl = "[ Yes ]"; - let no_lbl = "[ No ]"; - let yes_w = yes_lbl.len() as u16; - let no_w = no_lbl.len() as u16; - let total_btn_w = yes_w + no_w + 4; + let yes_lbl = "[ Yes ]"; + let no_lbl = "[ No ]"; + let yes_w: u16 = 10; + let no_w: u16 = 10; + let gap: u16 = 3; + let total_btn_w = yes_w + gap + no_w; let btn_x = inner.x + (inner.width.saturating_sub(total_btn_w)) / 2; - let sel_btn = Style::default().fg(palette::WHITE).bg(palette::PROCESSING_HEAT); - let unsel_btn = Style::default().fg(palette::FG).bg(palette::BG_2); + let sel_btn = Style::default().fg(palette::WHITE).bg(palette::PROCESSING_HEAT).add_modifier(Modifier::BOLD); + let unsel_btn = Style::default().fg(palette::FG).bg(palette::BG_2).add_modifier(Modifier::BOLD); let yes_style = if self.delete_focus == ProfDeleteFocus::YesBtn { sel_btn } else { unsel_btn }; let no_style = if self.delete_focus == ProfDeleteFocus::NoBtn { sel_btn } else { unsel_btn }; buf.set_string(btn_x, btn_y, yes_lbl, yes_style); - buf.set_string(btn_x + yes_w + 2, btn_y, no_lbl, no_style); + buf.set_string(btn_x + yes_w + gap, btn_y, no_lbl, no_style); Self::draw_shadow(buf, canvas, w, h); } diff --git a/src/ui/repomanager.rs b/src/ui/repomanager.rs index 8e3961fb..a759e107 100644 --- a/src/ui/repomanager.rs +++ b/src/ui/repomanager.rs @@ -2,6 +2,7 @@ use super::{ dslbrowser, palette, platforms, profiles, title::{self, TitleSegment, TitleStyle}, }; +use indexmap::IndexMap; use libsysinspect::console::{ConsoleModuleArgument, ConsoleModuleRow}; use ratatui::{ layout::{Constraint, Direction, Layout, Position}, @@ -25,6 +26,8 @@ pub struct StagedModule { pub descr: String, pub path: std::path::PathBuf, pub checked: bool, + pub platform: Option, + pub arch: Option, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -32,6 +35,7 @@ pub enum StagingFocus { List, AddSelected, Cancel, + CrossPlatformDelete, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -40,15 +44,17 @@ pub enum StagingMode { ModuleDelete, ProfileModuleAdd, ProfileLibraryAdd, - PlatformBuildAdd, } #[derive(Debug)] pub struct RepoManager { pub visible: bool, - pub rows: Vec, - pub cursor: usize, - pub scroll: Cell, + pub module_groups: IndexMap>, + pub group_order: Vec, + pub group_cursor: usize, + pub group_cursor_row: usize, + pub group_expanded: Vec, + pub group_scrolls: IndexMap>, // Staging pub staging: bool, @@ -57,6 +63,8 @@ pub struct RepoManager { pub staging_scroll: Cell, pub staging_focus: StagingFocus, pub staging_mode: StagingMode, + pub delete_mode: bool, + pub cross_platform_delete: bool, // Progress pub progress: Arc>>, @@ -64,7 +72,6 @@ pub struct RepoManager { // Signals pub bulk_add_triggered: bool, pub bulk_delete_triggered: bool, - pub delete_mode: bool, pub needs_reload: bool, // Filter @@ -259,6 +266,9 @@ impl RepoManager { if self.profiles.delete_visible { self.profiles.render_delete(parent, buf); } + if self.platforms.delete_visible { + self.platforms.render_delete(parent, buf); + } } fn render_main(&self, parent: Rect, buf: &mut Buffer) { From 22fb95a8b4e4c283111668778d3a281640e6e4f6 Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 13 Jun 2026 15:15:14 +0200 Subject: [PATCH 22/25] Add os/platform/arch groups to modules --- libmodpak/src/lib.rs | 7 ++ src/ui/mod.rs | 226 ++++++++++++++++++++++++++++-------- src/ui/repomanager.rs | 263 ++++++++++++++++++++++++++++++------------ 3 files changed, 380 insertions(+), 116 deletions(-) diff --git a/libmodpak/src/lib.rs b/libmodpak/src/lib.rs index 896d3019..9a56b4ea 100644 --- a/libmodpak/src/lib.rs +++ b/libmodpak/src/lib.rs @@ -1378,4 +1378,11 @@ impl SysInspectModPak { Ok(()) } + + /// Remove a single module entry for a specific platform and architecture. + pub fn remove_module_single(&mut self, name: &str, platform: &str, arch: &str) -> Result<(), SysinspectError> { + self.idx.remove_module(name, platform, arch)?; + fs::write(self.root.join(REPO_MOD_INDEX), self.idx.to_yaml()?)?; + Ok(()) + } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 2a35f78c..f50b4595 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1752,10 +1752,31 @@ impl SysInspectUX { }) .map_err(|e| format!("Failed to get module index: {e}"))?; match resp.payload { - ConsolePayload::MasterModuleIndex { mut rows } => { - rows.sort_by(|a, b| a.name.cmp(&b.name)); - self.repo_manager.rows = rows; - self.repo_manager.cursor = 0; + ConsolePayload::MasterModuleIndex { rows } => { + let mut groups: IndexMap> = IndexMap::new(); + for row in rows { + let key = format!("{} {}", row.platform, row.arch); + groups.entry(key).or_default().push(row); + } + // Merge platforms with no modules from the platform build list + if let Ok(repo) = SysInspectModPak::new(self.cfg.fileserver_root().join("repo")) { + for build in repo.minion_builds() { + let key = format!("{} {}", build.platform(), build.arch()); + groups.entry(key).or_default(); + } + } + let mut keys: Vec = groups.keys().cloned().collect(); + keys.sort(); + for rows in groups.values_mut() { + rows.sort_by(|a, b| a.name.cmp(&b.name)); + } + let n = groups.len(); + self.repo_manager.module_groups = groups; + self.repo_manager.group_order = keys; + self.repo_manager.group_cursor = 0; + self.repo_manager.group_cursor_row = 0; + self.repo_manager.group_expanded = vec![false; n]; + self.repo_manager.group_scrolls.clear(); Ok(()) } _ => Err("Unexpected console payload for module index".to_string()), @@ -1791,13 +1812,18 @@ impl SysInspectUX { } if self.repo_manager.bulk_delete_triggered { self.repo_manager.bulk_delete_triggered = false; - let checked: Vec<_> = self.repo_manager.staged.iter().filter(|m| m.checked).map(|m| m.name.clone()).collect(); + let checked: Vec<_> = self.repo_manager.staged.iter().filter(|m| m.checked).cloned().collect(); if checked.is_empty() { self.error_alert_visible = true; self.error_alert_message = "No items selected".to_string(); } else { self.repo_manager.exit_staging(); - self.bulk_delete_modules(&checked); + if self.repo_manager.cross_platform_delete { + let names: Vec = checked.iter().map(|m| m.name.clone()).collect(); + self.bulk_delete_modules(&names); + } else { + self.bulk_delete_single_platform(&checked); + } } } if !self.repo_manager.staging @@ -1815,11 +1841,11 @@ impl SysInspectUX { match e.code { KeyCode::Esc => { self.repo_manager.filter_focus = false; - self.repo_manager.cursor = 0; + self.repo_manager.group_cursor_row = 0; } KeyCode::Down | KeyCode::Tab | KeyCode::BackTab => { self.repo_manager.filter_focus = false; - self.repo_manager.cursor = 0; + self.repo_manager.group_cursor_row = 0; } KeyCode::Backspace => { self.repo_manager.filter.delete_before(); @@ -1922,6 +1948,11 @@ impl SysInspectUX { self.repo_manager.profiles.filtered_count(self.repo_manager.filter.value()) } else if self.repo_manager.active_tab == 1 { self.repo_filtered_lib_count() + } else if self.repo_manager.active_tab == 0 { + if let Some(rows) = self.repo_manager.focused_group_modules() { + let f = self.repo_manager.filter.value().to_lowercase(); + rows.iter().filter(|r| f.is_empty() || r.name.to_lowercase().contains(&f) || r.descr.to_lowercase().contains(&f)).count() + } else { 0 } } else { self.repo_filtered_count() }; @@ -1932,8 +1963,10 @@ impl SysInspectUX { &mut self.repo_manager.profiles.cursor } else if self.repo_manager.active_tab == 1 { &mut self.repo_manager.lib_cursor + } else if self.repo_manager.active_tab == 0 { + &mut self.repo_manager.group_cursor_row } else { - &mut self.repo_manager.cursor + &mut self.repo_manager.group_cursor_row // fallback for tab 0 }; let page = 10usize; match e.code { @@ -1944,7 +1977,8 @@ impl SysInspectUX { } KeyCode::Left => { self.repo_manager.active_tab = self.repo_manager.active_tab.saturating_sub(1); - self.repo_manager.cursor = 0; + self.repo_manager.group_cursor = 0; + self.repo_manager.group_cursor_row = 0; self.repo_manager.lib_cursor = 0; self.repo_manager.profiles.cursor = 0; self.repo_manager.platforms.cursor = 0; @@ -1960,7 +1994,8 @@ impl SysInspectUX { } KeyCode::Right => { self.repo_manager.active_tab = (self.repo_manager.active_tab + 1).min(3); - self.repo_manager.cursor = 0; + self.repo_manager.group_cursor = 0; + self.repo_manager.group_cursor_row = 0; self.repo_manager.lib_cursor = 0; self.repo_manager.profiles.cursor = 0; self.repo_manager.platforms.cursor = 0; @@ -1975,7 +2010,9 @@ impl SysInspectUX { } } KeyCode::Up => { - if self.repo_manager.active_tab == 2 { + if self.repo_manager.active_tab == 0 { + self.move_module_up(); + } else if self.repo_manager.active_tab == 2 { let fv = self.repo_manager.filter.value().to_string(); self.repo_manager.profiles.handle_list_key(e.code, &mut self.repo_manager.filter_focus, &fv); } else if self.repo_manager.active_tab == 3 { @@ -1985,7 +2022,9 @@ impl SysInspectUX { } } KeyCode::Down => { - if self.repo_manager.active_tab == 2 { + if self.repo_manager.active_tab == 0 { + self.move_module_down(); + } else if self.repo_manager.active_tab == 2 { let fv = self.repo_manager.filter.value().to_string(); self.repo_manager.profiles.handle_list_key(e.code, &mut self.repo_manager.filter_focus, &fv); } else if self.repo_manager.active_tab == 3 { @@ -1995,7 +2034,13 @@ impl SysInspectUX { } } KeyCode::PageUp => { - if self.repo_manager.active_tab == 2 { + if self.repo_manager.active_tab == 0 { + let n = self.repo_manager.group_order.len(); + if n > 0 { + self.repo_manager.group_cursor = (self.repo_manager.group_cursor + n - 1) % n; + self.repo_manager.group_cursor_row = 0; + } + } else if self.repo_manager.active_tab == 2 { let fv = self.repo_manager.filter.value().to_string(); self.repo_manager.profiles.handle_list_key(e.code, &mut self.repo_manager.filter_focus, &fv); } else if self.repo_manager.active_tab == 3 { @@ -2005,7 +2050,13 @@ impl SysInspectUX { } } KeyCode::PageDown => { - if self.repo_manager.active_tab == 2 { + if self.repo_manager.active_tab == 0 { + let n = self.repo_manager.group_order.len(); + if n > 0 { + self.repo_manager.group_cursor = (self.repo_manager.group_cursor + 1) % n; + self.repo_manager.group_cursor_row = 0; + } + } else if self.repo_manager.active_tab == 2 { let fv = self.repo_manager.filter.value().to_string(); self.repo_manager.profiles.handle_list_key(e.code, &mut self.repo_manager.filter_focus, &fv); } else if self.repo_manager.active_tab == 3 { @@ -2032,6 +2083,21 @@ impl SysInspectUX { self.error_alert_message = e; } } + } else if self.repo_manager.active_tab == 0 { + if self.repo_manager.group_cursor_row == 0 { + // Toggle expand/collapse on header + let gc = self.repo_manager.group_cursor; + if let Some(e) = self.repo_manager.group_expanded.get_mut(gc) { + *e = !*e; + } + } else if self.repo_manager.focused_module().is_some() { + self.repo_manager.info_visible = true; + self.repo_manager.info_row = self.repo_manager.group_cursor_row; + self.repo_manager.info_tab = 0; + self.repo_manager.info_scroll.set(0); + self.repo_manager.info_active_tab = 0; + self.status_at_repo_manager(); + } } else if self.repo_manager.active_tab == 1 { if !self.repo_manager.lib_rows.is_empty() { self.repo_manager.info_visible = true; @@ -2041,13 +2107,6 @@ impl SysInspectUX { self.repo_manager.info_active_tab = 1; self.status_at_repo_manager(); } - } else if !self.repo_manager.rows.is_empty() { - self.repo_manager.info_visible = true; - self.repo_manager.info_row = self.repo_manager.cursor; - self.repo_manager.info_tab = 0; - self.repo_manager.info_scroll.set(0); - self.repo_manager.info_active_tab = 0; - self.status_at_repo_manager(); } } KeyCode::Delete => { @@ -2072,28 +2131,37 @@ impl SysInspectUX { descr: r.checksum.clone(), path: std::path::PathBuf::new(), checked: false, + platform: None, + arch: None, }) .collect(); self.repo_manager.staging = true; self.repo_manager.staging_cursor = 0; self.repo_manager.staging_focus = repomanager::StagingFocus::List; - } else if !self.repo_manager.rows.is_empty() { - self.repo_manager.delete_mode = true; - self.repo_manager.staged = self - .repo_manager - .rows - .iter() - .map(|r| repomanager::StagedModule { - name: r.name.clone(), - version: r.version.clone(), - descr: r.descr.clone(), - path: std::path::PathBuf::new(), - checked: false, + } else if self.repo_manager.active_tab == 0 { + let staged_rows: Option> = { + let rm = &self.repo_manager; + rm.focused_group_modules().map(|rows| { + rows.iter().map(|r| repomanager::StagedModule { + name: r.name.clone(), + version: r.version.clone(), + descr: r.descr.clone(), + path: std::path::PathBuf::new(), + checked: false, + platform: Some(r.platform.clone()), + arch: Some(r.arch.clone()), + }).collect() }) - .collect(); - self.repo_manager.staging = true; - self.repo_manager.staging_cursor = 0; - self.repo_manager.staging_focus = repomanager::StagingFocus::List; + }; + if let Some(rows) = staged_rows { + self.repo_manager.delete_mode = true; + self.repo_manager.cross_platform_delete = false; + self.repo_manager.staged = rows; + self.repo_manager.staging_mode = repomanager::StagingMode::ModuleDelete; + self.repo_manager.staging = true; + self.repo_manager.staging_cursor = 0; + self.repo_manager.staging_focus = repomanager::StagingFocus::List; + } } } KeyCode::Insert | KeyCode::Char('i') if !e.modifiers.contains(KeyModifiers::CONTROL) => { @@ -2117,7 +2185,15 @@ impl SysInspectUX { } } KeyCode::Tab => { - self.repo_manager.filter_focus = true; + if self.repo_manager.active_tab == 0 { + let n = self.repo_manager.group_order.len().max(1); + let gc = self.repo_manager.group_cursor % n; + if let Some(e) = self.repo_manager.group_expanded.get_mut(gc) { + *e = !*e; + } + } else { + self.repo_manager.filter_focus = true; + } } KeyCode::Char('/') if !e.modifiers.contains(KeyModifiers::CONTROL) => { self.repo_manager.filter_focus = true; @@ -2127,9 +2203,43 @@ impl SysInspectUX { true } + fn move_module_up(&mut self) { + if self.repo_manager.group_cursor_row > 0 { + self.repo_manager.group_cursor_row -= 1; + } else { + let n = self.repo_manager.group_order.len(); + if n == 0 { return; } + self.repo_manager.group_cursor = (self.repo_manager.group_cursor + n - 1) % n; + let gc = self.repo_manager.group_cursor; + if self.repo_manager.group_expanded.get(gc).copied().unwrap_or(false) { + if let Some(rows) = self.repo_manager.focused_group_modules() { + self.repo_manager.group_cursor_row = rows.len(); + } else { + self.repo_manager.group_cursor_row = 0; + } + } else { + self.repo_manager.group_cursor_row = 0; + } + } + } + + fn move_module_down(&mut self) { + let n = self.repo_manager.group_order.len(); + if n == 0 { return; } + let gc = self.repo_manager.group_cursor % n; + if self.repo_manager.group_expanded.get(gc).copied().unwrap_or(false) + && let Some(rows) = self.repo_manager.focused_group_modules() + && self.repo_manager.group_cursor_row < rows.len() + { + self.repo_manager.group_cursor_row += 1; + } else { + self.repo_manager.group_cursor = (self.repo_manager.group_cursor + 1) % n; + self.repo_manager.group_cursor_row = 0; + } + } + fn repo_filtered_count(&self) -> usize { - let f = self.repo_manager.filter.value().to_lowercase(); - self.repo_manager.rows.iter().filter(|r| f.is_empty() || r.name.to_lowercase().contains(&f) || r.descr.to_lowercase().contains(&f)).count() + self.repo_manager.filtered_module_count(self.repo_manager.filter.value()) } fn repo_filtered_lib_count(&self) -> usize { @@ -2179,8 +2289,9 @@ impl SysInspectUX { .filter_map(|s| s.split_once(": ").map(|x| x.1)) .flat_map(|sel| { self.repo_manager - .rows - .iter() + .module_groups + .values() + .flatten() .filter(|r| glob::Pattern::new(sel).is_ok_and(|p| p.matches(&r.name))) .map(|r| profiles::ResolvedModule { name: r.name.clone(), @@ -2315,7 +2426,8 @@ impl SysInspectUX { self.error_alert_message = "No .spec files found in the selected directory".to_string(); } else { let total = staged.len(); - Self::dedup_staged_modules(&self.repo_manager.rows, &mut staged); + let flat_modules: Vec = self.repo_manager.module_groups.values().flatten().cloned().collect(); + Self::dedup_staged_modules(&flat_modules, &mut staged); let skipped = total - staged.len(); if staged.is_empty() { self.error_alert_visible = true; @@ -2335,6 +2447,8 @@ impl SysInspectUX { descr, path: path.to_path_buf(), checked: true, + platform: None, + arch: None, }]); } else { self.error_alert_visible = true; @@ -2359,7 +2473,7 @@ impl SysInspectUX { let module_name = Self::read_spec_name(&spec).unwrap_or_else(|| dir_name.clone()); let (version, descr) = Self::read_spec_version_descr(&spec); let bin = sub.join(&dir_name); - staged.push(repomanager::StagedModule { name: module_name, version, descr, path: if bin.exists() { bin } else { spec }, checked: true }); + staged.push(repomanager::StagedModule { name: module_name, version, descr, path: if bin.exists() { bin } else { spec }, checked: true, platform: None, arch: None }); } staged } @@ -2450,6 +2564,28 @@ impl SysInspectUX { } } + fn bulk_delete_single_platform(&mut self, checked: &[repomanager::StagedModule]) { + let repo_root = self.cfg.fileserver_root().join("repo"); + let mut repo = match SysInspectModPak::new(repo_root) { + Ok(r) => r, + Err(e) => { + self.error_alert_visible = true; + self.error_alert_message = format!("Cannot open repository: {e}"); + return; + } + }; + for m in checked { + if let (Some(ref platform), Some(ref arch)) = (m.platform.as_ref(), m.arch.as_ref()) { + if let Err(e) = repo.remove_module_single(&m.name, platform, arch) { + self.error_alert_visible = true; + self.error_alert_message = format!("Cannot remove module: {e}"); + return; + } + } + } + let _ = self.load_module_index(); + } + fn process_library_add(&mut self, path: &std::path::Path) { let repo_root = self.cfg.fileserver_root().join("repo"); let mut repo = match SysInspectModPak::new(repo_root) { diff --git a/src/ui/repomanager.rs b/src/ui/repomanager.rs index a759e107..1aad51e2 100644 --- a/src/ui/repomanager.rs +++ b/src/ui/repomanager.rs @@ -12,12 +12,13 @@ use ratatui::{ widgets::{Block, BorderType, Borders, Clear, Paragraph, StatefulWidget, Tabs, Widget}, }; use ratatui_cheese::input::{Input, InputState, InputStyles}; -use ratatui_glamour::color::blend_2d; +use ratatui_glamour::color::{blend_2d, lerp_color}; use ratatui_glamour::rule::dashed_title; use std::{ cell::Cell, sync::{Arc, Mutex}, }; +use unicode_width::UnicodeWidthStr; #[derive(Debug, Clone)] pub struct StagedModule { @@ -102,19 +103,23 @@ impl Default for RepoManager { fn default() -> Self { Self { visible: false, - rows: Vec::new(), - cursor: 0, - scroll: Cell::new(0), + module_groups: IndexMap::new(), + group_order: Vec::new(), + group_cursor: 0, + group_cursor_row: 0, + group_expanded: Vec::new(), + group_scrolls: IndexMap::new(), staging: false, staged: Vec::new(), staging_cursor: 0, staging_scroll: Cell::new(0), staging_focus: StagingFocus::List, staging_mode: StagingMode::ModuleAdd, + delete_mode: false, + cross_platform_delete: false, progress: Arc::new(Mutex::new(None)), bulk_add_triggered: false, bulk_delete_triggered: false, - delete_mode: false, needs_reload: false, filter: InputState::new(), filter_focus: false, @@ -145,19 +150,54 @@ impl RepoManager { pub fn exit_staging(&mut self) { self.staging = false; self.delete_mode = false; + self.cross_platform_delete = false; self.staged.clear(); } + pub fn focused_module(&self) -> Option<&ConsoleModuleRow> { + if self.group_cursor_row == 0 { return None; } + let key = self.group_order.get(self.group_cursor)?; + self.module_groups.get(key)?.get(self.group_cursor_row - 1) + } + + pub fn focused_group_modules(&self) -> Option<&Vec> { + let key = self.group_order.get(self.group_cursor)?; + self.module_groups.get(key) + } + + pub fn focused_group_name(&self) -> Option<&str> { + self.group_order.get(self.group_cursor).map(|s| s.as_str()) + } + + pub fn filtered_module_count(&self, filter_value: &str) -> usize { + let f = filter_value.to_lowercase(); + self.module_groups + .values() + .flat_map(|g| g.iter()) + .filter(|r| f.is_empty() || r.name.to_lowercase().contains(&f) || r.descr.to_lowercase().contains(&f)) + .count() + } + + pub fn focused_module_for_info(&self) -> Option<&ConsoleModuleRow> { + let key = self.group_order.get(self.group_cursor)?; + // info_row is 1-indexed (0 = header) + if self.info_row == 0 { return None; } + self.module_groups.get(key)?.get(self.info_row - 1) + } + pub fn enter_profile_module_staging(&mut self) { self.staged = self - .rows - .iter() + .module_groups + .values() + .flat_map(|g| g.iter()) .map(|r| StagedModule { name: r.name.clone(), version: r.version.clone(), descr: r.descr.clone(), path: std::path::PathBuf::new(), checked: false, + platform: Some(r.platform.clone()), + arch: Some(r.arch.clone()), }) .collect(); self.staging_cursor = 0; @@ -179,6 +219,8 @@ impl RepoManager { descr: r.checksum.clone(), path: std::path::PathBuf::new(), checked: false, + platform: None, + arch: None, }) .collect(); self.staging_cursor = 0; @@ -202,7 +244,8 @@ impl RepoManager { use StagingFocus::*; self.staging_focus = match self.staging_focus { List => AddSelected, - AddSelected => Cancel, + AddSelected => if self.delete_mode { CrossPlatformDelete } else { Cancel }, + CrossPlatformDelete => Cancel, Cancel => List, }; } @@ -211,9 +254,13 @@ impl RepoManager { self.staging_focus = match self.staging_focus { List => Cancel, AddSelected => List, - Cancel => AddSelected, + Cancel => if self.delete_mode { CrossPlatformDelete } else { AddSelected }, + CrossPlatformDelete => AddSelected, }; } + crossterm::event::KeyCode::Char(' ') if self.staging_focus == StagingFocus::CrossPlatformDelete => { + self.cross_platform_delete = !self.cross_platform_delete; + } crossterm::event::KeyCode::Up if self.staging_focus == StagingFocus::List => { self.staging_cursor = self.staging_cursor.saturating_sub(1); } @@ -385,7 +432,7 @@ impl RepoManager { return; } - let list_height = inner.height.saturating_sub(btn_height); + let list_height = inner.height.saturating_sub(btn_height).saturating_sub(if self.delete_mode { 1 } else { 0 }); let name_w: u16 = 28; let ver_w: u16 = 6; @@ -453,8 +500,30 @@ impl RepoManager { } } + // Cross-platform delete checkbox + if self.delete_mode { + let chk_y = inner.y + list_height + 1; + let (chk, chk_style) = if self.cross_platform_delete { + ("▣", Style::default().fg(palette::SUCCESS)) + } else { + ("□", Style::default().fg(palette::GRAY_1)) + }; + let chk_text = " Delete across all platforms"; + let sel = self.staging_focus == StagingFocus::CrossPlatformDelete; + if sel { + for cx in 0..inner.width { + if let Some(cell) = buf.cell_mut(Position::new(inner.x + cx, chk_y)) { + cell.set_bg(palette::HIGHLIGHT); + } + } + } + let row_style = if sel { Style::default().fg(palette::BLACK).bg(palette::HIGHLIGHT) } else { Style::default().fg(palette::FG) }; + buf.set_string(inner.x + 1, chk_y, chk, if sel { row_style } else { chk_style }); + buf.set_string(inner.x + 5, chk_y, chk_text, row_style); + } + // Buttons - let btn_y = inner.y + list_height + 1; + let btn_y = inner.y + list_height + (if self.delete_mode { 2 } else { 1 }); let action_label = if self.delete_mode { "[ Delete ]" } else { "[ Add Selected ]" }; let cancel_label = "[ Cancel ]"; let action_w = action_label.len() as u16; @@ -544,75 +613,105 @@ impl RepoManager { .try_into() .unwrap(); Self::render_filter_row(filter_area, buf, self.filter_focus, &self.filter); - let name_w: u16 = 28; - let ver_w: u16 = 6; - if self.rows.is_empty() { + + if self.module_groups.is_empty() { let msg = "(no modules found)"; let x = list_area.x + (list_area.width.saturating_sub(msg.len() as u16)) / 2; let y = list_area.y + list_area.height / 2; buf.set_string(x, y, msg, Style::default().fg(palette::MUTED)); return; } + let flt = self.filter.value().to_lowercase(); - let filtered: Vec<(usize, &ConsoleModuleRow)> = self - .rows - .iter() - .enumerate() - .filter(|(_, r)| flt.is_empty() || r.name.to_lowercase().contains(&flt) || r.descr.to_lowercase().contains(&flt)) - .collect(); - let view_h = list_area.height as usize; - let total = filtered.len(); - let max_scroll = total.saturating_sub(view_h); - let mut s = self.scroll.get(); - let cursor = self.cursor.min(total.saturating_sub(1)); - if cursor < s { - s = cursor; - } - if cursor >= s + view_h { - s = cursor.saturating_sub(view_h.saturating_sub(1)); - } - s = s.min(max_scroll); - self.scroll.set(s); - if total == 0 { - let msg = "(no matches)"; - let x = list_area.x + (list_area.width.saturating_sub(msg.len() as u16)) / 2; - let y = list_area.y + list_area.height / 2; - buf.set_string(x, y, msg, Style::default().fg(palette::MUTED)); - return; - } - let hl = Style::default().fg(palette::BLACK).bg(palette::HIGHLIGHT); - for i in 0..view_h.min(total.saturating_sub(s)) { - let fi = s + i; - let (_oi, row) = filtered[fi]; - let ry = list_area.y + i as u16; - let sel = !self.filter_focus && fi == cursor; - let row_style = if sel { hl } else { Style::default().fg(palette::FG) }; - if sel { - for cx in 0..list_area.width { - if let Some(cell) = buf.cell_mut(Position::new(list_area.x + cx, ry)) { - cell.set_bg(palette::HIGHLIGHT); + let name_w: u16 = 28; + let ver_w: u16 = 6; + let mut row_y = list_area.y; + let preview_rows: usize = 3; + + for (gi, key) in self.group_order.iter().enumerate() { + if row_y >= list_area.bottom() { break; } + let modules = match self.module_groups.get(key) { Some(m) => m, None => continue }; + let filtered: Vec<&ConsoleModuleRow> = modules.iter() + .filter(|r| flt.is_empty() || r.name.to_lowercase().contains(&flt) || r.descr.to_lowercase().contains(&flt)) + .collect(); + let count = filtered.len(); + let expanded = self.group_expanded.get(gi).copied().unwrap_or(false); + let chevron = if expanded { "▼" } else { "▶" }; + let focused = !self.filter_focus && gi == self.group_cursor; + let header_fg = if focused { palette::HIGHLIGHT } else { palette::MUTED }; + let count_text = format!(" ({count})"); + + // Header row + buf.set_string(list_area.x + 1, row_y, chevron, Style::default().fg(header_fg).add_modifier(if focused { Modifier::BOLD } else { Modifier::empty() })); + let label = format!(" {key}{count_text} "); + buf.set_string(list_area.x + 4, row_y, &label, Style::default().fg(header_fg).add_modifier(if focused { Modifier::BOLD } else { Modifier::empty() })); + let label_w = UnicodeWidthStr::width(label.as_str()) as u16; + let fill_start = list_area.x + 4 + label_w; + let fill_end = list_area.right().saturating_sub(1); + for fx in fill_start..fill_end { + if let Some(cell) = buf.cell_mut(Position::new(fx, row_y)) { + let t = if fill_end > fill_start + 1 { + (fx - fill_start) as f32 / (fill_end - fill_start).saturating_sub(1) as f32 + } else { 0.0 }; + let color = lerp_color(palette::PRIMARY, palette::PROCESSING_DIMMED, t); + cell.set_char('/'); + cell.set_fg(color); + } + } + row_y += 1; + if row_y >= list_area.bottom() { break; } + + if expanded { + // Expanded: show all rows with scrollbar + let remaining = (list_area.bottom().saturating_sub(row_y)) as usize; + if remaining == 0 { continue; } + let view_h = remaining.min(count); + let total = filtered.len(); + let max_scroll = total.saturating_sub(view_h); + let mut s = self.group_scrolls.get(key).map(|c| c.get()).unwrap_or(0).min(max_scroll); + let cursor_in_group = if focused && gi == self.group_cursor && self.group_cursor_row > 0 { + Some(self.group_cursor_row - 1) // 1-indexed → 0-indexed + } else { None }; + if let Some(c) = cursor_in_group { + if c < s { s = c; } + if c >= s + view_h { s = c.saturating_sub(view_h.saturating_sub(1)); } + s = s.min(max_scroll); + } + if let Some(cell) = self.group_scrolls.get(key) { cell.set(s); } + self.render_module_rows(list_area, &filtered, s, view_h, row_y, focused, cursor_in_group, name_w, ver_w, buf); + if total > view_h { + self.draw_scrollbar(buf, Rect { x: list_area.x, y: row_y, width: list_area.width, height: view_h as u16 }, s, total, view_h); + } + row_y += view_h as u16; + } else if count > 0 { + // Collapsed: show preview rows + summary + let show = preview_rows.min(count); + let cursor_in_group = if focused && gi == self.group_cursor && self.group_cursor_row > 0 && self.group_cursor_row <= preview_rows { + Some(self.group_cursor_row - 1) + } else { None }; + for i in 0..show { + if row_y >= list_area.bottom() { break; } + if let Some(row) = filtered.get(i) { + render_module_row(list_area, row_y, row, focused && cursor_in_group == Some(i), name_w, ver_w, buf); } + row_y += 1; + } + if count > preview_rows && row_y < list_area.bottom() { + let more = format!(" ({more})...", more = count - preview_rows); + buf.set_string(list_area.x + 1, row_y, &more, Style::default().fg(palette::MUTED)); + row_y += 1; } } - buf.set_string(list_area.x + 1, ry, format!(" {}", truncate_str(&row.name, name_w as usize)), row_style); - let ver_style = if sel { row_style } else { Style::default().fg(palette::HIGHLIGHT) }; - buf.set_string(list_area.x + 1 + name_w + 1, ry, truncate_str(row.version.as_deref().unwrap_or("—"), ver_w as usize), ver_style); - let desc_style = if sel { row_style } else { Style::default().fg(palette::GRAY_1) }; - let desc_x = list_area.x + 1 + name_w + 1 + ver_w + 1; - let max_desc = (list_area.width.saturating_sub(name_w + ver_w + 3)) as usize; - buf.set_string(desc_x, ry, truncate_str(&row.descr, max_desc), desc_style); + // Empty group with 0 modules: just the header, no rows } - if total > view_h { - let bh = ((view_h as f64 / total as f64) * view_h as f64).max(1.0) as usize; - let by = ((s as f64 / total as f64) * (view_h - bh) as f64) as usize; - for i in 0..view_h { - let sx = list_area.right().saturating_sub(1); - let sy = list_area.y + i as u16; - if i >= by && i < by + bh { - buf.set_string(sx, sy, "█", Style::default().fg(palette::PROCESSING_HEAT)); - } else { - buf.set_string(sx, sy, "│", Style::default().fg(palette::MUTED)); - } + } + + fn render_module_rows(&self, area: Rect, filtered: &[&ConsoleModuleRow], offset: usize, view_h: usize, start_y: u16, focused: bool, cursor: Option, name_w: u16, ver_w: u16, buf: &mut Buffer) { + for i in 0..view_h { + let idx = offset + i; + let ry = start_y + i as u16; + if let Some(row) = filtered.get(idx) { + render_module_row(area, ry, row, focused && cursor == Some(idx), name_w, ver_w, buf); } } } @@ -781,7 +880,7 @@ impl RepoManager { } fn render_module_info(&self, parent: Rect, buf: &mut Buffer) { - let row = match self.rows.get(self.info_row) { + let row = match self.focused_module_for_info() { Some(r) => r, None => return, }; @@ -815,7 +914,7 @@ impl RepoManager { buf, canvas, &title_style, - &[TitleSegment { text: format!(" {} ", row.name), bg: palette::PROCESSING_BASE, fg: palette::FG, modifier: Modifier::empty() }], + &[TitleSegment { text: format!(" {} ({} {}) ", row.name, row.platform, row.arch), bg: palette::PROCESSING_BASE, fg: palette::FG, modifier: Modifier::empty() }], ); if inner.height < 4 { @@ -1235,6 +1334,28 @@ fn render_markup_spans(input: &str) -> ratatui::text::Line<'static> { ratatui::text::Line::from(spans) } +fn render_module_row(area: Rect, ry: u16, row: &ConsoleModuleRow, sel: bool, name_w: u16, ver_w: u16, buf: &mut Buffer) { + let hl = Style::default().fg(palette::BLACK).bg(palette::HIGHLIGHT); + let fg = Style::default().fg(palette::FG); + let ver_fg = Style::default().fg(palette::HIGHLIGHT); + let desc_fg = Style::default().fg(palette::GRAY_1); + let row_style = if sel { hl } else { fg }; + if sel { + for cx in 0..area.width { + if let Some(cell) = buf.cell_mut(Position::new(area.x + cx, ry)) { + cell.set_bg(palette::HIGHLIGHT); + } + } + } + buf.set_string(area.x + 1, ry, format!(" {}", truncate_str(&row.name, name_w as usize)), row_style); + let ver_style = if sel { row_style } else { ver_fg }; + buf.set_string(area.x + 1 + name_w + 1, ry, truncate_str(row.version.as_deref().unwrap_or("—"), ver_w as usize), ver_style); + let desc_style = if sel { row_style } else { desc_fg }; + let desc_x = area.x + 1 + name_w + 1 + ver_w + 1; + let max_desc = (area.width.saturating_sub(name_w + ver_w + 3)) as usize; + buf.set_string(desc_x, ry, truncate_str(&row.descr, max_desc), desc_style); +} + fn truncate_str(s: &str, max_w: usize) -> String { if s.len() <= max_w { s.to_string() } else { format!("{}…", &s[..max_w.saturating_sub(1)]) } } From 49e7edc23ba56d926ca9e5d1e166815d20a54b5e Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 13 Jun 2026 15:34:35 +0200 Subject: [PATCH 23/25] Cleanup TUI, lintfixes --- libmodpak/src/lib.rs | 5 +- src/ui/mod.rs | 79 ++++++++++-------- src/ui/platforms.rs | 32 ++++++-- src/ui/profiles.rs | 2 +- src/ui/repomanager.rs | 185 ++++++++++++++++++++++++++++++------------ 5 files changed, 203 insertions(+), 100 deletions(-) diff --git a/libmodpak/src/lib.rs b/libmodpak/src/lib.rs index 9a56b4ea..b5e84239 100644 --- a/libmodpak/src/lib.rs +++ b/libmodpak/src/lib.rs @@ -838,10 +838,7 @@ impl SysInspectModPak { return Err(SysinspectError::MasterGeneralError("Minion build must be an ELF executable".to_string())); } if Self::requires_static_minion(os) && !Self::is_static_elf(&buff)? { - return Err(SysinspectError::MasterGeneralError(format!( - "{} minion build must be a static ELF", - Self::minion_platform_label(os) - ))); + return Err(SysinspectError::MasterGeneralError(format!("{} minion build must be a static ELF", Self::minion_platform_label(os)))); } let version = Self::get_minion_version(&buff) .ok_or_else(|| SysinspectError::MasterGeneralError("Minion build must be a sysminion executable".to_string()))?; diff --git a/src/ui/mod.rs b/src/ui/mod.rs index f50b4595..de5ff048 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -13,6 +13,7 @@ use libmodpak::{SysInspectModPak, mpk::ModPakMetadata}; use libsysinspect::{ cfg::mmconf::MasterConfig, console::{ConsoleMinionInfoRow, ConsoleModelRow, ConsoleModuleRow, ConsoleOnlineMinionRow, ConsolePayload}, + traits::os_display_name, }; use libsysproto::query::{ SCHEME_COMMAND, @@ -1755,13 +1756,13 @@ impl SysInspectUX { ConsolePayload::MasterModuleIndex { rows } => { let mut groups: IndexMap> = IndexMap::new(); for row in rows { - let key = format!("{} {}", row.platform, row.arch); + let key = format!("{} {}", os_display_name(&row.platform), row.arch); groups.entry(key).or_default().push(row); } // Merge platforms with no modules from the platform build list if let Ok(repo) = SysInspectModPak::new(self.cfg.fileserver_root().join("repo")) { for build in repo.minion_builds() { - let key = format!("{} {}", build.platform(), build.arch()); + let key = format!("{} {}", os_display_name(build.platform()), build.arch()); groups.entry(key).or_default(); } } @@ -1952,7 +1953,9 @@ impl SysInspectUX { if let Some(rows) = self.repo_manager.focused_group_modules() { let f = self.repo_manager.filter.value().to_lowercase(); rows.iter().filter(|r| f.is_empty() || r.name.to_lowercase().contains(&f) || r.descr.to_lowercase().contains(&f)).count() - } else { 0 } + } else { + 0 + } } else { self.repo_filtered_count() }; @@ -1963,10 +1966,8 @@ impl SysInspectUX { &mut self.repo_manager.profiles.cursor } else if self.repo_manager.active_tab == 1 { &mut self.repo_manager.lib_cursor - } else if self.repo_manager.active_tab == 0 { - &mut self.repo_manager.group_cursor_row } else { - &mut self.repo_manager.group_cursor_row // fallback for tab 0 + &mut self.repo_manager.group_cursor_row }; let page = 10usize; match e.code { @@ -2098,15 +2099,13 @@ impl SysInspectUX { self.repo_manager.info_active_tab = 0; self.status_at_repo_manager(); } - } else if self.repo_manager.active_tab == 1 { - if !self.repo_manager.lib_rows.is_empty() { - self.repo_manager.info_visible = true; - self.repo_manager.info_row = self.repo_manager.lib_cursor; - self.repo_manager.info_tab = 0; - self.repo_manager.info_scroll.set(0); - self.repo_manager.info_active_tab = 1; - self.status_at_repo_manager(); - } + } else if self.repo_manager.active_tab == 1 && !self.repo_manager.lib_rows.is_empty() { + self.repo_manager.info_visible = true; + self.repo_manager.info_row = self.repo_manager.lib_cursor; + self.repo_manager.info_tab = 0; + self.repo_manager.info_scroll.set(0); + self.repo_manager.info_active_tab = 1; + self.status_at_repo_manager(); } } KeyCode::Delete => { @@ -2142,15 +2141,17 @@ impl SysInspectUX { let staged_rows: Option> = { let rm = &self.repo_manager; rm.focused_group_modules().map(|rows| { - rows.iter().map(|r| repomanager::StagedModule { - name: r.name.clone(), - version: r.version.clone(), - descr: r.descr.clone(), - path: std::path::PathBuf::new(), - checked: false, - platform: Some(r.platform.clone()), - arch: Some(r.arch.clone()), - }).collect() + rows.iter() + .map(|r| repomanager::StagedModule { + name: r.name.clone(), + version: r.version.clone(), + descr: r.descr.clone(), + path: std::path::PathBuf::new(), + checked: false, + platform: Some(r.platform.clone()), + arch: Some(r.arch.clone()), + }) + .collect() }) }; if let Some(rows) = staged_rows { @@ -2208,7 +2209,9 @@ impl SysInspectUX { self.repo_manager.group_cursor_row -= 1; } else { let n = self.repo_manager.group_order.len(); - if n == 0 { return; } + if n == 0 { + return; + } self.repo_manager.group_cursor = (self.repo_manager.group_cursor + n - 1) % n; let gc = self.repo_manager.group_cursor; if self.repo_manager.group_expanded.get(gc).copied().unwrap_or(false) { @@ -2225,7 +2228,9 @@ impl SysInspectUX { fn move_module_down(&mut self) { let n = self.repo_manager.group_order.len(); - if n == 0 { return; } + if n == 0 { + return; + } let gc = self.repo_manager.group_cursor % n; if self.repo_manager.group_expanded.get(gc).copied().unwrap_or(false) && let Some(rows) = self.repo_manager.focused_group_modules() @@ -2473,7 +2478,15 @@ impl SysInspectUX { let module_name = Self::read_spec_name(&spec).unwrap_or_else(|| dir_name.clone()); let (version, descr) = Self::read_spec_version_descr(&spec); let bin = sub.join(&dir_name); - staged.push(repomanager::StagedModule { name: module_name, version, descr, path: if bin.exists() { bin } else { spec }, checked: true, platform: None, arch: None }); + staged.push(repomanager::StagedModule { + name: module_name, + version, + descr, + path: if bin.exists() { bin } else { spec }, + checked: true, + platform: None, + arch: None, + }); } staged } @@ -2575,12 +2588,12 @@ impl SysInspectUX { } }; for m in checked { - if let (Some(ref platform), Some(ref arch)) = (m.platform.as_ref(), m.arch.as_ref()) { - if let Err(e) = repo.remove_module_single(&m.name, platform, arch) { - self.error_alert_visible = true; - self.error_alert_message = format!("Cannot remove module: {e}"); - return; - } + if let (Some(platform), Some(arch)) = (m.platform.as_ref(), m.arch.as_ref()) + && let Err(e) = repo.remove_module_single(&m.name, platform, arch) + { + self.error_alert_visible = true; + self.error_alert_message = format!("Cannot remove module: {e}"); + return; } } let _ = self.load_module_index(); diff --git a/src/ui/platforms.rs b/src/ui/platforms.rs index 650f3e32..c155ceb3 100644 --- a/src/ui/platforms.rs +++ b/src/ui/platforms.rs @@ -41,7 +41,14 @@ pub enum DeleteFocus { impl Default for PlatformsManager { fn default() -> Self { - Self { rows: Vec::new(), cursor: 0, scroll: Cell::new(0), delete_visible: false, delete_name: String::new(), delete_focus: DeleteFocus::YesBtn } + Self { + rows: Vec::new(), + cursor: 0, + scroll: Cell::new(0), + delete_visible: false, + delete_name: String::new(), + delete_focus: DeleteFocus::YesBtn, + } } } @@ -165,7 +172,9 @@ impl PlatformsManager { for idx in 0..dlg_w { let sx = x.saturating_add(2).saturating_add(idx); let sy = y.saturating_add(dlg_h); - if sx > max_x || sy > max_y { continue; } + if sx > max_x || sy > max_y { + continue; + } if let Some(cell) = buf.cell_mut(Position::new(sx, sy)) { cell.set_bg(palette::SHADOW_BG); cell.set_fg(palette::SHADOW_FG); @@ -175,7 +184,9 @@ impl PlatformsManager { for idx in 0..dlg_h { let sx = x.saturating_add(dlg_w).saturating_add(offset); let sy = y.saturating_add(idx).saturating_add(1); - if sx > max_x || sy > max_y { continue; } + if sx > max_x || sy > max_y { + continue; + } if let Some(cell) = buf.cell_mut(Position::new(sx, sy)) { cell.set_bg(palette::SHADOW_BG); cell.set_fg(palette::SHADOW_FG); @@ -263,12 +274,17 @@ impl PlatformsManager { } } } - buf.set_string(list_area.x + 1, ry, &format!(" {}", truncate_str(&display_platform, plat_w as usize)), row_style); - buf.set_string(list_area.x + 1 + plat_w + 1, ry, &format!(" {}", truncate_str(&row.arch, arch_w as usize)), row_style); - buf.set_string(list_area.x + 1 + plat_w + 1 + arch_w + 1, ry, &format!(" {}", truncate_str(&row.version, ver_w as usize)), row_style); - buf.set_string(list_area.x + 1 + plat_w + 1 + arch_w + 1 + ver_w + 1, ry, &format!(" {}", truncate_str(&row.size, size_w as usize)), row_style); + buf.set_string(list_area.x + 1, ry, format!(" {}", truncate_str(&display_platform, plat_w as usize)), row_style); + buf.set_string(list_area.x + 1 + plat_w + 1, ry, format!(" {}", truncate_str(&row.arch, arch_w as usize)), row_style); + buf.set_string(list_area.x + 1 + plat_w + 1 + arch_w + 1, ry, format!(" {}", truncate_str(&row.version, ver_w as usize)), row_style); + buf.set_string( + list_area.x + 1 + plat_w + 1 + arch_w + 1 + ver_w + 1, + ry, + format!(" {}", truncate_str(&row.size, size_w as usize)), + row_style, + ); let sum_x = list_area.x + 1 + plat_w + 1 + arch_w + 1 + ver_w + 1 + size_w + 1; - buf.set_string(sum_x, ry, &format!(" {}", truncate_str(&row.checksum, sum_w as usize)), row_style); + buf.set_string(sum_x, ry, format!(" {}", truncate_str(&row.checksum, sum_w as usize)), row_style); } if total > view_h { diff --git a/src/ui/profiles.rs b/src/ui/profiles.rs index 5485ab5f..7f3df316 100644 --- a/src/ui/profiles.rs +++ b/src/ui/profiles.rs @@ -425,7 +425,7 @@ impl ProfilesManager { } } } - buf.set_string(list_area.x + 1, ry, &format!(" {}", name), row_style); + buf.set_string(list_area.x + 1, ry, format!(" {}", name), row_style); } if total > view_h { diff --git a/src/ui/repomanager.rs b/src/ui/repomanager.rs index 1aad51e2..70257526 100644 --- a/src/ui/repomanager.rs +++ b/src/ui/repomanager.rs @@ -155,7 +155,9 @@ impl RepoManager { } pub fn focused_module(&self) -> Option<&ConsoleModuleRow> { - if self.group_cursor_row == 0 { return None; } + if self.group_cursor_row == 0 { + return None; + } let key = self.group_order.get(self.group_cursor)?; self.module_groups.get(key)?.get(self.group_cursor_row - 1) } @@ -181,7 +183,9 @@ impl RepoManager { pub fn focused_module_for_info(&self) -> Option<&ConsoleModuleRow> { let key = self.group_order.get(self.group_cursor)?; // info_row is 1-indexed (0 = header) - if self.info_row == 0 { return None; } + if self.info_row == 0 { + return None; + } self.module_groups.get(key)?.get(self.info_row - 1) } @@ -244,7 +248,13 @@ impl RepoManager { use StagingFocus::*; self.staging_focus = match self.staging_focus { List => AddSelected, - AddSelected => if self.delete_mode { CrossPlatformDelete } else { Cancel }, + AddSelected => { + if self.delete_mode { + CrossPlatformDelete + } else { + Cancel + } + } CrossPlatformDelete => Cancel, Cancel => List, }; @@ -254,7 +264,13 @@ impl RepoManager { self.staging_focus = match self.staging_focus { List => Cancel, AddSelected => List, - Cancel => if self.delete_mode { CrossPlatformDelete } else { AddSelected }, + Cancel => { + if self.delete_mode { + CrossPlatformDelete + } else { + AddSelected + } + } CrossPlatformDelete => AddSelected, }; } @@ -348,15 +364,24 @@ impl RepoManager { let tab_names = ["Modules", "Libraries", "Profiles", "Platforms"]; let section_name = tab_names[self.active_tab as usize]; let title_style = TitleStyle::cyberpunk(palette::PROCESSING_GLOW); - title::overlay_gradient_title( - buf, - canvas, - &title_style, - &[ - TitleSegment { text: " Artefacts ".into(), bg: palette::PROCESSING_BASE, fg: palette::FG, modifier: Modifier::empty() }, - TitleSegment { text: format!(" {section_name} "), bg: palette::PROCESSING_HEAT, fg: palette::FG, modifier: Modifier::empty() }, - ], - ); + + let mut segments = + vec![TitleSegment { text: " Artefacts ".into(), bg: palette::PROCESSING_BASE, fg: palette::FG, modifier: Modifier::empty() }]; + + if self.active_tab == 0 + && let Some(name) = self.focused_group_name() + { + let mut parts = name.splitn(2, ' '); + let platform = parts.next().unwrap_or("?"); + let arch = parts.next().unwrap_or("?"); + segments.push(TitleSegment { text: format!(" {platform} "), bg: palette::PROCESSING_GLOW, fg: palette::FG, modifier: Modifier::empty() }); + segments.push(TitleSegment { text: format!(" {arch} "), bg: palette::PROCESSING_HEAT, fg: palette::FG, modifier: Modifier::empty() }); + } + + let tab_bg = if segments.len() > 2 { palette::PROCESSING_PEAK } else { palette::PROCESSING_HEAT }; + segments.push(TitleSegment { text: format!(" {section_name} "), bg: tab_bg, fg: palette::FG, modifier: Modifier::empty() }); + + title::overlay_gradient_title(buf, canvas, &title_style, &segments); if inner.height < 4 { return; @@ -629,11 +654,15 @@ impl RepoManager { let preview_rows: usize = 3; for (gi, key) in self.group_order.iter().enumerate() { - if row_y >= list_area.bottom() { break; } - let modules = match self.module_groups.get(key) { Some(m) => m, None => continue }; - let filtered: Vec<&ConsoleModuleRow> = modules.iter() - .filter(|r| flt.is_empty() || r.name.to_lowercase().contains(&flt) || r.descr.to_lowercase().contains(&flt)) - .collect(); + if row_y >= list_area.bottom() { + break; + } + let modules = match self.module_groups.get(key) { + Some(m) => m, + None => continue, + }; + let filtered: Vec<&ConsoleModuleRow> = + modules.iter().filter(|r| flt.is_empty() || r.name.to_lowercase().contains(&flt) || r.descr.to_lowercase().contains(&flt)).collect(); let count = filtered.len(); let expanded = self.group_expanded.get(gi).copied().unwrap_or(false); let chevron = if expanded { "▼" } else { "▶" }; @@ -641,56 +670,76 @@ impl RepoManager { let header_fg = if focused { palette::HIGHLIGHT } else { palette::MUTED }; let count_text = format!(" ({count})"); + let group_start_y = row_y; + // Header row - buf.set_string(list_area.x + 1, row_y, chevron, Style::default().fg(header_fg).add_modifier(if focused { Modifier::BOLD } else { Modifier::empty() })); + buf.set_string( + list_area.x + 2, + row_y, + chevron, + Style::default().fg(header_fg).add_modifier(if focused { Modifier::BOLD } else { Modifier::empty() }), + ); let label = format!(" {key}{count_text} "); - buf.set_string(list_area.x + 4, row_y, &label, Style::default().fg(header_fg).add_modifier(if focused { Modifier::BOLD } else { Modifier::empty() })); + buf.set_string( + list_area.x + 3, + row_y, + &label, + Style::default().fg(header_fg).add_modifier(if focused { Modifier::BOLD } else { Modifier::empty() }), + ); let label_w = UnicodeWidthStr::width(label.as_str()) as u16; - let fill_start = list_area.x + 4 + label_w; - let fill_end = list_area.right().saturating_sub(1); + let fill_start = list_area.x + 3 + label_w; + let fill_end = list_area.right().saturating_sub(2); for fx in fill_start..fill_end { if let Some(cell) = buf.cell_mut(Position::new(fx, row_y)) { - let t = if fill_end > fill_start + 1 { - (fx - fill_start) as f32 / (fill_end - fill_start).saturating_sub(1) as f32 - } else { 0.0 }; + let t = if fill_end > fill_start + 1 { (fx - fill_start) as f32 / (fill_end - fill_start).saturating_sub(1) as f32 } else { 0.0 }; let color = lerp_color(palette::PRIMARY, palette::PROCESSING_DIMMED, t); cell.set_char('/'); cell.set_fg(color); } } row_y += 1; - if row_y >= list_area.bottom() { break; } + let mut overflow_scroll: Option<(usize, usize, usize)> = None; if expanded { - // Expanded: show all rows with scrollbar let remaining = (list_area.bottom().saturating_sub(row_y)) as usize; - if remaining == 0 { continue; } - let view_h = remaining.min(count); - let total = filtered.len(); - let max_scroll = total.saturating_sub(view_h); - let mut s = self.group_scrolls.get(key).map(|c| c.get()).unwrap_or(0).min(max_scroll); - let cursor_in_group = if focused && gi == self.group_cursor && self.group_cursor_row > 0 { - Some(self.group_cursor_row - 1) // 1-indexed → 0-indexed - } else { None }; - if let Some(c) = cursor_in_group { - if c < s { s = c; } - if c >= s + view_h { s = c.saturating_sub(view_h.saturating_sub(1)); } - s = s.min(max_scroll); - } - if let Some(cell) = self.group_scrolls.get(key) { cell.set(s); } - self.render_module_rows(list_area, &filtered, s, view_h, row_y, focused, cursor_in_group, name_w, ver_w, buf); - if total > view_h { - self.draw_scrollbar(buf, Rect { x: list_area.x, y: row_y, width: list_area.width, height: view_h as u16 }, s, total, view_h); + if remaining == 0 { + row_y = group_start_y + 1; + } else { + let view_h = remaining.min(count); + let total = filtered.len(); + let max_scroll = total.saturating_sub(view_h); + let mut s = self.group_scrolls.get(key).map(|c| c.get()).unwrap_or(0).min(max_scroll); + let cursor_in_group = + if focused && gi == self.group_cursor && self.group_cursor_row > 0 { Some(self.group_cursor_row - 1) } else { None }; + if let Some(c) = cursor_in_group { + if c < s { + s = c; + } + if c >= s + view_h { + s = c.saturating_sub(view_h.saturating_sub(1)); + } + s = s.min(max_scroll); + } + if let Some(cell) = self.group_scrolls.get(key) { + cell.set(s); + } + self.render_module_rows(list_area, &filtered, s, view_h, row_y, focused, cursor_in_group, name_w, ver_w, buf); + if total > view_h { + overflow_scroll = Some((s, total, view_h)); + } + row_y += view_h as u16; } - row_y += view_h as u16; } else if count > 0 { - // Collapsed: show preview rows + summary let show = preview_rows.min(count); let cursor_in_group = if focused && gi == self.group_cursor && self.group_cursor_row > 0 && self.group_cursor_row <= preview_rows { Some(self.group_cursor_row - 1) - } else { None }; + } else { + None + }; for i in 0..show { - if row_y >= list_area.bottom() { break; } + if row_y >= list_area.bottom() { + break; + } if let Some(row) = filtered.get(i) { render_module_row(list_area, row_y, row, focused && cursor_in_group == Some(i), name_w, ver_w, buf); } @@ -698,15 +747,38 @@ impl RepoManager { } if count > preview_rows && row_y < list_area.bottom() { let more = format!(" ({more})...", more = count - preview_rows); - buf.set_string(list_area.x + 1, row_y, &more, Style::default().fg(palette::MUTED)); + let max_w = list_area.width.saturating_sub(4) as usize; + buf.set_string(list_area.x + 1, row_y, truncate_str(&more, max_w), Style::default().fg(palette::MUTED)); row_y += 1; } } - // Empty group with 0 modules: just the header, no rows + + // Scrollbar track: always draw on every row of this group + let sb_x = list_area.right().saturating_sub(1); + let sb_style = Style::default().fg(palette::MUTED); + for ty in group_start_y..row_y { + buf.set_string(sb_x, ty, "│", sb_style); + } + // Thumb overlay for overflow + if let Some((off, total, view_h)) = overflow_scroll { + let bar_h = ((view_h as f64 / total as f64) * view_h as f64).max(1.0) as usize; + let bar_h = bar_h.min(view_h); + let bar_y = ((off as f64 / (total - view_h) as f64) * (view_h - bar_h) as f64) as usize; + for i in bar_y..bar_y + bar_h { + let ty = group_start_y + 1 + i as u16; + if ty < row_y { + buf.set_string(sb_x, ty, "█", Style::default().fg(palette::PROCESSING_HEAT)); + } + } + } } } - fn render_module_rows(&self, area: Rect, filtered: &[&ConsoleModuleRow], offset: usize, view_h: usize, start_y: u16, focused: bool, cursor: Option, name_w: u16, ver_w: u16, buf: &mut Buffer) { + #[allow(clippy::too_many_arguments)] + fn render_module_rows( + &self, area: Rect, filtered: &[&ConsoleModuleRow], offset: usize, view_h: usize, start_y: u16, focused: bool, cursor: Option, + name_w: u16, ver_w: u16, buf: &mut Buffer, + ) { for i in 0..view_h { let idx = offset + i; let ry = start_y + i as u16; @@ -914,7 +986,12 @@ impl RepoManager { buf, canvas, &title_style, - &[TitleSegment { text: format!(" {} ({} {}) ", row.name, row.platform, row.arch), bg: palette::PROCESSING_BASE, fg: palette::FG, modifier: Modifier::empty() }], + &[TitleSegment { + text: format!(" {} ({} {}) ", row.name, row.platform, row.arch), + bg: palette::PROCESSING_BASE, + fg: palette::FG, + modifier: Modifier::empty(), + }], ); if inner.height < 4 { @@ -1341,7 +1418,7 @@ fn render_module_row(area: Rect, ry: u16, row: &ConsoleModuleRow, sel: bool, nam let desc_fg = Style::default().fg(palette::GRAY_1); let row_style = if sel { hl } else { fg }; if sel { - for cx in 0..area.width { + for cx in 0..area.width.saturating_sub(2) { if let Some(cell) = buf.cell_mut(Position::new(area.x + cx, ry)) { cell.set_bg(palette::HIGHLIGHT); } @@ -1352,7 +1429,7 @@ fn render_module_row(area: Rect, ry: u16, row: &ConsoleModuleRow, sel: bool, nam buf.set_string(area.x + 1 + name_w + 1, ry, truncate_str(row.version.as_deref().unwrap_or("—"), ver_w as usize), ver_style); let desc_style = if sel { row_style } else { desc_fg }; let desc_x = area.x + 1 + name_w + 1 + ver_w + 1; - let max_desc = (area.width.saturating_sub(name_w + ver_w + 3)) as usize; + let max_desc = (area.width.saturating_sub(name_w + ver_w + 5)) as usize; buf.set_string(desc_x, ry, truncate_str(&row.descr, max_desc), desc_style); } From 3f7686afb8e2ed2c7dbc3d48a55cc8cdeccbe7dc Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 13 Jun 2026 16:27:16 +0200 Subject: [PATCH 24/25] Fix test build on GNU --- modules/fs/dir/build.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/modules/fs/dir/build.rs b/modules/fs/dir/build.rs index c88a9993..b15697a6 100644 --- a/modules/fs/dir/build.rs +++ b/modules/fs/dir/build.rs @@ -1,4 +1,10 @@ include!("../../build-help.rs"); fn main() { generate_help(); + + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + let target_env = std::env::var("CARGO_CFG_TARGET_ENV").unwrap_or_default(); + if target_os == "linux" && target_env == "gnu" { + println!("cargo:rustc-link-lib=c"); + } } From 454fac9844d4edabc9644ac98cdd2ce5a0922cfa Mon Sep 17 00:00:00 2001 From: Bo Maryniuk Date: Sat, 13 Jun 2026 22:37:57 +0200 Subject: [PATCH 25/25] Fix journal integration test --- libsysinspect/tests/journal_integration.rs | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/libsysinspect/tests/journal_integration.rs b/libsysinspect/tests/journal_integration.rs index fb21a119..de2bc898 100644 --- a/libsysinspect/tests/journal_integration.rs +++ b/libsysinspect/tests/journal_integration.rs @@ -1,6 +1,7 @@ use libsysinspect::journal::Journal; use std::sync::Arc; use std::thread; +use std::time::Duration; fn temp_dir(label: &str) -> std::path::PathBuf { let dir = std::env::temp_dir().join(format!( @@ -13,6 +14,21 @@ fn temp_dir(label: &str) -> std::path::PathBuf { dir } +fn open_with_retry(path: &std::path::Path, max_bytes: u64) -> Journal { + let mut last_err = None; + for _ in 0..20 { + match Journal::open(path, max_bytes) { + Ok(journal) => return journal, + Err(err) => { + last_err = Some(err); + thread::sleep(Duration::from_millis(10)); + } + } + } + + panic!("failed to reopen journal after bounded retries: {:?}", last_err); +} + #[test] fn concurrent_appends_across_threads() { let dir = temp_dir("concurrent"); @@ -60,7 +76,7 @@ fn crash_recovery_after_append() { // Simulate crash: drop without ack } { - let j = Journal::open(&dir, 0).unwrap(); + let j = open_with_retry(&dir, 0); let pending = j.pending().unwrap(); assert_eq!(pending.len(), 50); assert_eq!(pending[0].0, "c-0000"); @@ -76,7 +92,7 @@ fn crash_recovery_after_append() { } { // Reopen: only 25 remain - let j = Journal::open(&dir, 0).unwrap(); + let j = open_with_retry(&dir, 0); let pending = j.pending().unwrap(); assert_eq!(pending.len(), 25); }