From 52ac2357f3c2e76a1ff46881ab3279ebf94b43d4 Mon Sep 17 00:00:00 2001 From: Laan Tungir Date: Wed, 19 Aug 2026 13:32:10 -0400 Subject: [PATCH] v0.0.6 - TUI: add Edit role command, Up/Down role selection for Edit/Delete, no highlight on Add --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/lib.rs | 2 +- src/tui.rs | 219 +++++++++++++++++++++++++++++++++++++++-------------- 4 files changed, 164 insertions(+), 61 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b382e4f..758f055 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1490,7 +1490,7 @@ dependencies = [ [[package]] name = "nsigner" -version = "0.0.4" +version = "0.0.5" dependencies = [ "base64", "chacha20poly1305", diff --git a/Cargo.toml b/Cargo.toml index af74905..f5dcdec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nsigner" -version = "0.0.5" +version = "0.0.6" edition = "2021" license = "MIT" description = "Attended Nostr signing daemon — Rust port of n_signer" diff --git a/src/lib.rs b/src/lib.rs index ff78ac2..3029342 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -31,4 +31,4 @@ pub mod error; pub use error::NsignerError; /// Version string (matches C NSIGNER_VERSION). -pub const VERSION: &str = "v0.0.5"; +pub const VERSION: &str = "v0.0.6"; diff --git a/src/tui.rs b/src/tui.rs index eeca22d..fb8e06d 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -39,8 +39,8 @@ pub const TRANSPORT_HTTP: u8 = 0x08; pub const QREXEC_SERVICE_NAME: &str = "qubes.NsignerRpc"; /// Number of focusable commands on the main screen: -/// 4 transports + Add + Delete + Clear + Help + Quit. -pub const FOCUS_COUNT: usize = 9; +/// 4 transports + Add + Edit + Delete + Clear + Help + Quit. +pub const FOCUS_COUNT: usize = 10; /// Look up the local FIPS node identity by running `fipsctl show status`. /// Returns the node's `npub` (a Nostr public-key identifier) when both @@ -387,6 +387,10 @@ pub struct App { pub role_add_default_purpose: RolePurpose, /// Saved OTP pad directory between the OtpDir and OtpName stages. pub role_add_otp_dir: String, + /// If true, the add-role popup is editing an existing role instead of + /// creating a new one. The original name is stored for lookup. + pub role_edit_mode: bool, + pub role_edit_original_name: String, // Transport pub transport_toggles: [bool; 4], @@ -399,8 +403,8 @@ pub struct App { // Unified command focus. /// Index of the currently focused command on the main screen. - /// 0-3 = transports (Unix, Qube, FIPS, HTTP), 4 = Add, 5 = Delete, - /// 6 = Clear, 7 = Help, 8 = Quit. Tab cycles forward. + /// 0-3 = transports (Unix, Qube, FIPS, HTTP), 4 = Add, 5 = Edit, + /// 6 = Delete, 7 = Clear, 8 = Help, 9 = Quit. Tab cycles forward. pub focus: usize, // Main screen @@ -448,6 +452,8 @@ impl App { role_add_default_curve: "secp256k1".to_string(), role_add_default_purpose: RolePurpose::Nostr, role_add_otp_dir: String::new(), + role_edit_mode: false, + role_edit_original_name: String::new(), transport_toggles: [true, false, false, false], transport_mask: TRANSPORT_UNIX, transport_cursor: 0, @@ -663,10 +669,15 @@ impl App { self.should_quit = true; } KeyCode::Char('a') | KeyCode::Char('A') => { + self.role_edit_mode = false; + self.role_edit_original_name.clear(); self.role_add_stage = RoleAddStage::PresetMenu; self.role_add_choice = 1; self.screen = Screen::AddRole; } + KeyCode::Char('e') | KeyCode::Char('E') => { + self.edit_selected_role(); + } KeyCode::Char('d') | KeyCode::Char('D') => { self.delete_selected_role(); } @@ -698,14 +709,28 @@ impl App { } // Enter activates whichever command is currently focused. KeyCode::Enter => self.activate_focused(), - // Up/Down and j/k scroll the activity log. + // Up/Down and j/k move role selection when a role command + // (Edit / Delete) is focused, otherwise scroll the log. KeyCode::Up | KeyCode::Char('k') => { - if self.activity_scroll > 0 { + if self.focus == 5 || self.focus == 6 { + // Move role cursor up + if self.role_cursor > 0 { + self.role_cursor -= 1; + } + } else if self.activity_scroll > 0 { self.activity_scroll -= 1; } } KeyCode::Down | KeyCode::Char('j') => { - self.activity_scroll += 1; + if self.focus == 5 || self.focus == 6 { + // Move role cursor down + let last = self.role_table.entries.len().saturating_sub(1); + if self.role_cursor < last { + self.role_cursor += 1; + } + } else { + self.activity_scroll += 1; + } } _ => {} } @@ -719,17 +744,20 @@ impl App { 2 => self.toggle_transport(2), 3 => self.toggle_transport(3), 4 => { + self.role_edit_mode = false; + self.role_edit_original_name.clear(); self.role_add_stage = RoleAddStage::PresetMenu; self.role_add_choice = 1; self.screen = Screen::AddRole; } - 5 => self.delete_selected_role(), - 6 => { + 5 => self.edit_selected_role(), + 6 => self.delete_selected_role(), + 7 => { self.activity_log.clear(); self.activity_scroll = 0; } - 7 => self.screen = Screen::Help, - 8 => self.should_quit = true, + 8 => self.screen = Screen::Help, + 9 => self.should_quit = true, _ => {} } } @@ -778,10 +806,34 @@ impl App { } } + /// Open the add-role popup pre-filled with the selected role's data + /// for editing. Sets `role_edit_mode` so that on confirm the old + /// entry is replaced rather than a new one being added. + fn edit_selected_role(&mut self) { + let count = self.role_table.entries.len(); + if count == 0 || self.role_cursor >= count { + return; + } + let entry = &self.role_table.entries[self.role_cursor]; + self.role_edit_mode = true; + self.role_edit_original_name = entry.name.clone(); + self.role_add_default_name = entry.name.clone(); + self.role_add_default_path = entry.role_path.clone(); + self.role_add_default_curve = entry.curve_str.clone(); + self.role_add_default_purpose = entry.purpose; + self.role_add_choice = 10; // custom — skip preset, go to NameEntry + self.role_add_input = InputField::new(&entry.name); + self.role_add_stage = RoleAddStage::NameEntry; + self.role_add_name = entry.name.clone(); + self.screen = Screen::AddRole; + } + // ── Add-role popup ────────────────────────────────────────────────── fn handle_add_role_key(&mut self, key: KeyEvent) { if key.code == KeyCode::Esc { + self.role_edit_mode = false; + self.role_edit_original_name.clear(); self.screen = Screen::Main; return; } @@ -985,25 +1037,41 @@ impl App { self.role_add_input = InputField::new(""); return; } - if self.role_table.find_by_name(&name).is_some() { - // Duplicate — back to preset + // In edit mode, the name may match the original — that's fine. + // Only reject duplicates that belong to a *different* role. + if !self.role_edit_mode { + if self.role_table.find_by_name(&name).is_some() { + // Duplicate — back to preset + self.role_add_stage = RoleAddStage::PresetMenu; + return; + } + } else if name != self.role_edit_original_name + && self.role_table.find_by_name(&name).is_some() + { + // Renamed to collide with another existing role. self.role_add_stage = RoleAddStage::PresetMenu; return; } self.role_add_name = name.clone(); - match self.role_add_choice { - // Custom: pick the curve next, then edit the path. - 10 => { - self.role_add_stage = RoleAddStage::CurveSelect; - } - // OTP: collect the pad directory and pad name next. - 9 => { - self.role_add_input = InputField::new(""); - self.role_add_stage = RoleAddStage::OtpDir; - } - // Presets with a fixed path: register immediately. - _ => { - self.add_role_register_path(&name); + if self.role_edit_mode { + // Editing: go straight to path entry (curve is already known). + self.role_add_input = InputField::new(&self.role_add_default_path); + self.role_add_stage = RoleAddStage::PathEntry; + } else { + match self.role_add_choice { + // Custom: pick the curve next, then edit the path. + 10 => { + self.role_add_stage = RoleAddStage::CurveSelect; + } + // OTP: collect the pad directory and pad name next. + 9 => { + self.role_add_input = InputField::new(""); + self.role_add_stage = RoleAddStage::OtpDir; + } + // Presets with a fixed path: register immediately. + _ => { + self.add_role_register_path(&name); + } } } } @@ -1032,32 +1100,57 @@ impl App { let purpose = self.role_add_default_purpose; match crate::role_table::parse_path_template(path) { Ok((template, range_lo, range_hi, allowed_indices)) => { - match self.role_table.register_role_path( - &name, - &template, - purpose, - curve, - range_lo, - range_hi, - -1, - &allowed_indices, - ) { - Ok(()) => { + if self.role_edit_mode { + // Replace the old entry in-place. + let old_name = self.role_edit_original_name.clone(); + if let Some(pos) = self.role_table.entries.iter().position(|e| e.name == old_name) + { + self.role_table.entries[pos].name = name.clone(); + self.role_table.entries[pos].purpose = purpose; + self.role_table.entries[pos].curve = curve; + self.role_table.entries[pos].role_path = template.clone(); + self.role_table.entries[pos].path_range_lo = range_lo; + self.role_table.entries[pos].path_range_hi = range_hi; + self.role_table.entries[pos].path_allowed_indices = allowed_indices.clone(); + self.role_table.entries[pos].path_default_index = -1; + // Re-derive this role's key. + self.key_store.wipe(); + let _ = self.key_store.derive_all(&mut self.role_table, &self.mnemonic); self.activity_log - .add(&format!("role '{}' registered", name)); - // Derive the new role's key immediately. - if let Some(idx) = - self.role_table.entries.iter().position(|e| e.name == name) - { - let _ = self - .key_store - .derive_one(&mut self.role_table, &self.mnemonic, idx); - } - self.role_cursor = self.role_table.entries.len().saturating_sub(1); - // Return to Main immediately — no extra Enter needed. - self.screen = Screen::Main; + .add(&format!("role '{}' updated", name)); + self.role_cursor = pos; + } + self.role_edit_mode = false; + self.role_edit_original_name.clear(); + self.screen = Screen::Main; + } else { + match self.role_table.register_role_path( + &name, + &template, + purpose, + curve, + range_lo, + range_hi, + -1, + &allowed_indices, + ) { + Ok(()) => { + self.activity_log + .add(&format!("role '{}' registered", name)); + // Derive the new role's key immediately. + if let Some(idx) = + self.role_table.entries.iter().position(|e| e.name == name) + { + let _ = self + .key_store + .derive_one(&mut self.role_table, &self.mnemonic, idx); + } + self.role_cursor = self.role_table.entries.len().saturating_sub(1); + // Return to Main immediately — no extra Enter needed. + self.screen = Screen::Main; + } + Err(_) => {} } - Err(_) => {} } } Err(_) => { @@ -1295,9 +1388,9 @@ impl App { self.draw_roles_section(f, roles); self.draw_activity_section(f, right); - // Bottom bar: Help (focus 7) / Quit (focus 8). - let help_focused = self.focus == 7; - let quit_focused = self.focus == 8; + // Bottom bar: Help (focus 8) / Quit (focus 9). + let help_focused = self.focus == 8; + let quit_focused = self.focus == 9; let mut help_spans = App::cmd_hint('l', "Help"); if help_focused { for s in &mut help_spans { @@ -1438,10 +1531,10 @@ impl App { )), ]; for (i, e) in self.role_table.entries.iter().enumerate() { - // Only highlight a role row when a role command (Add/Delete) + // Only highlight a role row when a role command (Edit/Delete) // is focused, so the table doesn't show a stale highlight by // default. - let selected = (self.focus == 4 || self.focus == 5) && self.role_cursor == i; + let selected = (self.focus == 5 || self.focus == 6) && self.role_cursor == i; let row = Line::from(format!( " {:<14} {:<8} {:<12} {}", e.name, e.purpose_str, e.curve_str, e.display_path() @@ -1455,9 +1548,10 @@ impl App { } f.render_widget(Paragraph::new(lines), rows_area); - // Add (focus 4) / Delete (focus 5) — reverse-highlight when focused. + // Add (focus 4) / Edit (focus 5) / Delete (focus 6) — reverse-highlight when focused. let add_focused = self.focus == 4; - let del_focused = self.focus == 5; + let edit_focused = self.focus == 5; + let del_focused = self.focus == 6; let mut spans = Vec::new(); let mut add_spans = App::cmd_hint('a', "Add"); if add_focused { @@ -1467,6 +1561,14 @@ impl App { } spans.extend(add_spans); spans.push(Span::raw(" ")); + let mut edit_spans = App::cmd_hint('e', "Edit"); + if edit_focused { + for s in &mut edit_spans { + *s = s.clone().style(Style::default().add_modifier(Modifier::REVERSED)); + } + } + spans.extend(edit_spans); + spans.push(Span::raw(" ")); let mut del_spans = App::cmd_hint('d', "Delete"); if del_focused { for s in &mut del_spans { @@ -1512,7 +1614,7 @@ impl App { .end_symbol(Some("▼")); f.render_stateful_widget(sb, log_area, &mut scrollbar_state); - let clear_focused = self.focus == 6; + let clear_focused = self.focus == 7; let mut spans = App::cmd_hint('c', "Clear"); if clear_focused { for s in &mut spans { @@ -1671,6 +1773,7 @@ impl App { Line::from("F Toggle FIPS transport"), Line::from("H Toggle HTTP transport"), Line::from("A Add a role"), + Line::from("E Edit the selected role"), Line::from("D Delete the selected role"), Line::from("C Clear the activity log"), Line::from("L Open the Help screen"),