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));