v0.0.6 - TUI: add Edit role command, Up/Down role selection for Edit/Delete, no highlight on Add

This commit is contained in:
Laan Tungir
2026-08-19 13:32:10 -04:00
parent 2cd64032ee
commit 52ac2357f3
4 changed files with 164 additions and 61 deletions
Generated
+1 -1
View File
@@ -1490,7 +1490,7 @@ dependencies = [
[[package]]
name = "nsigner"
version = "0.0.4"
version = "0.0.5"
dependencies = [
"base64",
"chacha20poly1305",
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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";
+161 -58
View File
@@ -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"),