From 50278821ca9d71ab3ce944983d1d27d405547a54 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Wed, 18 Mar 2026 18:49:43 +0100 Subject: [PATCH] implemented adding new vars --- src/app.rs | 52 ++++++++++++++++++++++++++++++-------- src/config.rs | 6 +++++ src/format/hierarchical.rs | 42 +++++++++++++++++++++--------- src/format/ini.rs | 47 ++++++++++++++++++++++++++++------ src/format/properties.rs | 45 ++++++++++++++++++++++++++++----- src/runner.rs | 31 ++++++++++++++++++----- src/ui.rs | 6 ++++- 7 files changed, 185 insertions(+), 44 deletions(-) diff --git a/src/app.rs b/src/app.rs index 94cf9f5..1e320b1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -217,6 +217,7 @@ impl App { let mut p = new_path.clone(); p.extend(var.path[old_path.len()..].iter().cloned()); var.path = p; + var.status = crate::format::ItemStatus::Modified; } } } @@ -229,6 +230,11 @@ impl App { /// Transitions the application into Insert Mode for keys. pub fn enter_insert_key(&mut self) { if !self.vars.is_empty() { + if let Some(var) = self.vars.get(self.selected) + && matches!(var.path.last(), Some(PathSegment::Index(_))) { + self.status_message = Some("Cannot rename array indices".to_string()); + return; + } self.mode = Mode::InsertKey; self.sync_input_with_selected(); } @@ -331,24 +337,28 @@ impl App { } /// Adds a new item relative to the selected item. - pub fn add_item(&mut self, after: bool) { + pub fn add_item(&mut self, after: bool, is_group: bool) { if self.vars.is_empty() { let new_key = "NEW_VAR".to_string(); self.vars.push(ConfigItem { key: new_key.clone(), path: vec![PathSegment::Key(new_key)], - value: Some("".to_string()), + value: if is_group { None } else { Some("".to_string()) }, template_value: None, default_value: None, depth: 0, - is_group: false, + is_group, status: crate::format::ItemStatus::Modified, - value_type: crate::format::ValueType::String, + value_type: if is_group { crate::format::ValueType::Null } else { crate::format::ValueType::String }, }); self.selected = 0; self.sync_input_with_selected(); self.save_undo_state(); - self.enter_insert(InsertVariant::Start); + if is_group { + self.enter_insert_key(); + } else { + self.enter_insert(InsertVariant::Start); + } return; } @@ -418,7 +428,7 @@ impl App { } } else { let mut count = 1; - let mut candidate = "NEW_VAR".to_string(); + let mut candidate = if is_group { "NEW_GROUP".to_string() } else { "NEW_VAR".to_string() }; let parent_path_slice = new_path.as_slice(); while self.vars.iter().any(|v| { @@ -426,7 +436,7 @@ impl App { && v.path.len() == parent_path_slice.len() + 1 && v.key == candidate }) { - candidate = format!("NEW_VAR_{}", count); + candidate = if is_group { format!("NEW_GROUP_{}", count) } else { format!("NEW_VAR_{}", count) }; count += 1; } new_path.push(PathSegment::Key(candidate.clone())); @@ -437,13 +447,13 @@ impl App { let new_item = ConfigItem { key: final_key, path: new_path, - value: Some("".to_string()), + value: if is_group { None } else { Some("".to_string()) }, template_value: None, default_value: None, depth: new_depth, - is_group: false, + is_group, status: crate::format::ItemStatus::Modified, - value_type: crate::format::ValueType::String, + value_type: if is_group { crate::format::ValueType::Null } else { crate::format::ValueType::String }, }; self.vars.insert(insert_pos, new_item); @@ -458,6 +468,28 @@ impl App { self.status_message = None; } + /// Toggles the group status of the currently selected item. + pub fn toggle_group_selected(&mut self) { + if let Some(var) = self.vars.get_mut(self.selected) { + // Cannot toggle array items (always vars) + if matches!(var.path.last(), Some(PathSegment::Index(_))) { + self.status_message = Some("Cannot toggle array items".to_string()); + return; + } + + var.is_group = !var.is_group; + if var.is_group { + var.value = None; + var.value_type = crate::format::ValueType::Null; + } else { + var.value = Some("".to_string()); + var.value_type = crate::format::ValueType::String; + } + var.status = crate::format::ItemStatus::Modified; + self.sync_input_with_selected(); + } + } + /// Status bar helpers pub fn selected_is_group(&self) -> bool { self.vars.get(self.selected).map(|v| v.is_group).unwrap_or(false) diff --git a/src/config.rs b/src/config.rs index 479cb83..2dadd70 100644 --- a/src/config.rs +++ b/src/config.rs @@ -122,6 +122,9 @@ pub struct KeybindsConfig { pub undo: String, pub redo: String, pub rename: String, + pub append_group: String, + pub prepend_group: String, + pub toggle_group: String, } impl Default for KeybindsConfig { @@ -146,6 +149,9 @@ pub struct KeybindsConfig { undo: "u".to_string(), redo: "U".to_string(), rename: "r".to_string(), + append_group: "alt+o".to_string(), + prepend_group: "alt+O".to_string(), + toggle_group: "t".to_string(), } } } diff --git a/src/format/hierarchical.rs b/src/format/hierarchical.rs index 686799f..29a336b 100644 --- a/src/format/hierarchical.rs +++ b/src/format/hierarchical.rs @@ -576,25 +576,43 @@ enabled = true } #[test] - fn test_xml_flatten_unflatten() { - let xml_str = "8080true"; - - let json_val = xml_to_json(xml_str).unwrap(); - + fn test_group_rename_write() { let mut vars = Vec::new(); - flatten(&json_val, Vec::new(), Some("".to_string()), 0, &mut vars); + let json = serde_json::json!({ + "old_group": { + "key": "val" + } + }); + + flatten(&json, Vec::new(), Some("".to_string()), 0, &mut vars); + assert_eq!(vars.len(), 2); + assert_eq!(vars[0].key, "old_group"); + assert_eq!(vars[0].is_group, true); + assert_eq!(vars[1].key, "key"); + assert_eq!(vars[1].path_string(), "old_group.key"); + + // Manually simulate a rename of "old_group" to "new_group" + let old_path = vars[0].path.clone(); + let new_key = "new_group".to_string(); + let mut new_path = vec![PathSegment::Key(new_key.clone())]; + vars[0].key = new_key; + vars[0].path = new_path.clone(); + + // Update child path + vars[1].path = vec![PathSegment::Key("new_group".to_string()), PathSegment::Key("key".to_string())]; + + let handler = HierarchicalHandler::new(FormatType::Json); let mut root = Value::Object(Map::new()); - for var in vars { + for var in &vars { if !var.is_group { insert_into_value(&mut root, &var.path, var.value.as_deref().unwrap_or(""), var.value_type); } } - let unflattened_xml = json_to_xml(&root); - - assert!(unflattened_xml.contains("8080")); - assert!(unflattened_xml.contains("true")); - assert!(unflattened_xml.contains("") && unflattened_xml.contains("")); + let out = serde_json::to_string(&root).unwrap(); + assert!(out.contains("\"new_group\"")); + assert!(out.contains("\"key\":\"val\"")); + assert!(!out.contains("\"old_group\"")); } } diff --git a/src/format/ini.rs b/src/format/ini.rs index 6888dc8..3e3581f 100644 --- a/src/format/ini.rs +++ b/src/format/ini.rs @@ -80,15 +80,46 @@ mod tests { use std::io::Write; #[test] - fn test_parse_ini() { - let mut file = NamedTempFile::new().unwrap(); - writeln!(file, "[server]\nport=8080\n[database]\nhost=localhost").unwrap(); - + fn test_section_rename_write() { let handler = IniHandler; - let vars = handler.parse(file.path()).unwrap(); + let mut vars = vec![ + ConfigItem { + key: "server".to_string(), + path: vec![PathSegment::Key("server".to_string())], + value: None, + template_value: None, + default_value: None, + depth: 0, + is_group: true, + status: ItemStatus::Present, + value_type: ValueType::Null, + }, + ConfigItem { + key: "port".to_string(), + path: vec![PathSegment::Key("server".to_string()), PathSegment::Key("port".to_string())], + value: Some("8080".to_string()), + template_value: Some("8080".to_string()), + default_value: Some("8080".to_string()), + depth: 1, + is_group: false, + status: ItemStatus::Present, + value_type: ValueType::String, + } + ]; + + // Rename "server" to "srv" + vars[0].key = "srv".to_string(); + vars[0].path = vec![PathSegment::Key("srv".to_string())]; - assert!(vars.iter().any(|v| v.path_string() == "server" && v.is_group)); - assert!(vars.iter().any(|v| v.path_string() == "server.port" && v.value.as_deref() == Some("8080"))); - assert!(vars.iter().any(|v| v.path_string() == "database.host" && v.value.as_deref() == Some("localhost"))); + // Update child path + vars[1].path = vec![PathSegment::Key("srv".to_string()), PathSegment::Key("port".to_string())]; + + let file = NamedTempFile::new().unwrap(); + handler.write(file.path(), &vars).unwrap(); + + let content = std::fs::read_to_string(file.path()).unwrap(); + assert!(content.contains("[srv]")); + assert!(content.contains("port=8080")); + assert!(!content.contains("[server]")); } } diff --git a/src/format/properties.rs b/src/format/properties.rs index 4ca0b7d..c363849 100644 --- a/src/format/properties.rs +++ b/src/format/properties.rs @@ -89,14 +89,45 @@ mod tests { use std::io::Write; #[test] - fn test_parse_properties() { - let mut file = NamedTempFile::new().unwrap(); - writeln!(file, "server.port=8080\ndatabase.host=localhost").unwrap(); - + fn test_group_rename_write() { let handler = PropertiesHandler; - let vars = handler.parse(file.path()).unwrap(); + let mut vars = vec![ + ConfigItem { + key: "server".to_string(), + path: vec![PathSegment::Key("server".to_string())], + value: None, + template_value: None, + default_value: None, + depth: 0, + is_group: true, + status: ItemStatus::Present, + value_type: ValueType::Null, + }, + ConfigItem { + key: "port".to_string(), + path: vec![PathSegment::Key("server".to_string()), PathSegment::Key("port".to_string())], + value: Some("8080".to_string()), + template_value: Some("8080".to_string()), + default_value: Some("8080".to_string()), + depth: 1, + is_group: false, + status: ItemStatus::Present, + value_type: ValueType::String, + } + ]; + + // Rename "server" to "srv" + vars[0].key = "srv".to_string(); + vars[0].path = vec![PathSegment::Key("srv".to_string())]; - assert!(vars.iter().any(|v| v.path_string() == "server" && v.is_group)); - assert!(vars.iter().any(|v| v.path_string() == "server.port" && v.value.as_deref() == Some("8080"))); + // Update child path + vars[1].path = vec![PathSegment::Key("srv".to_string()), PathSegment::Key("port".to_string())]; + + let file = NamedTempFile::new().unwrap(); + handler.write(file.path(), &vars).unwrap(); + + let content = std::fs::read_to_string(file.path()).unwrap(); + assert!(content.contains("srv.port=8080")); + assert!(!content.contains("server.port=8080")); } } diff --git a/src/runner.rs b/src/runner.rs index 2b8507b..06806e9 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -110,8 +110,19 @@ where /// Handles primary navigation (j/k) and transitions to insert or command modes. fn handle_navigation_mode(&mut self, key: KeyEvent) -> io::Result<()> { - if let KeyCode::Char(c) = key.code { - self.key_sequence.push(c); + let key_str = if let KeyCode::Char(c) = key.code { + let mut s = String::new(); + if key.modifiers.contains(event::KeyModifiers::ALT) { + s.push_str("alt+"); + } + s.push(c); + s + } else { + String::new() + }; + + if !key_str.is_empty() { + self.key_sequence.push_str(&key_str); // Collect all configured keybinds let binds = [ @@ -131,6 +142,9 @@ where (&self.config.keybinds.undo, "undo"), (&self.config.keybinds.redo, "redo"), (&self.config.keybinds.rename, "rename"), + (&self.config.keybinds.append_group, "append_group"), + (&self.config.keybinds.prepend_group, "prepend_group"), + (&self.config.keybinds.toggle_group, "toggle_group"), (&"a".to_string(), "add_missing"), (&":".to_string(), "command"), (&"q".to_string(), "quit"), @@ -165,12 +179,18 @@ where "previous_match" => self.app.jump_previous_match(), "jump_top" => self.app.jump_top(), "jump_bottom" => self.app.jump_bottom(), - "append_item" => self.app.add_item(true), - "prepend_item" => self.app.add_item(false), + "append_item" => self.app.add_item(true, false), + "prepend_item" => self.app.add_item(false, false), "delete_item" => self.app.delete_selected(), "undo" => self.app.undo(), "redo" => self.app.redo(), "rename" => self.app.enter_insert_key(), + "append_group" => self.app.add_item(true, true), + "prepend_group" => self.app.add_item(false, true), + "toggle_group" => { + self.app.toggle_group_selected(); + self.app.save_undo_state(); + } "add_missing" => { self.add_missing_item(); } @@ -182,9 +202,8 @@ where _ => {} } } else if !prefix_match { - // Not an exact match and not a prefix for any bind, clear and restart seq self.key_sequence.clear(); - self.key_sequence.push(c); + self.key_sequence.push_str(&key_str); } } else { // Non-character keys reset the sequence buffer diff --git a/src/ui.rs b/src/ui.rs index 9eb638c..1c9de80 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -238,7 +238,7 @@ pub fn draw(f: &mut Frame, app: &mut App, config: &Config) { f.render_widget(input, chunks[2]); // Position the terminal cursor correctly when in Insert mode. - if let Mode::Insert = app.mode { + if matches!(app.mode, Mode::Insert) || matches!(app.mode, Mode::InsertKey) { f.set_cursor_position(ratatui::layout::Position::new( chunks[2].x + 1 + cursor_pos as u16, chunks[2].y + 1, @@ -292,11 +292,15 @@ pub fn draw(f: &mut Frame, app: &mut App, config: &Config) { parts.push(format!("{}/{}/{} edit", kb.edit, kb.edit_append, kb.edit_substitute)); } parts.push(format!("{} rename", kb.rename)); + parts.push(format!("{} toggle", kb.toggle_group)); if app.selected_is_missing() { parts.push(format!("{} add", "a")); // 'a' is currently hardcoded in runner } if app.selected_is_array() { parts.push(format!("{}/{} array", kb.append_item, kb.prepend_item)); + } else { + parts.push(format!("{}/{} add", kb.append_item, kb.prepend_item)); + parts.push(format!("{}/{} group", kb.append_group, kb.prepend_group)); } parts.push(format!("{} del", kb.delete_item)); parts.push(format!("{} undo", kb.undo));