release/0.5.0 #15

Merged
nvrl merged 16 commits from release/0.5.0 into main 2026-03-18 22:50:11 +01:00
7 changed files with 185 additions and 44 deletions
Showing only changes of commit 50278821ca - Show all commits

View File

@@ -217,6 +217,7 @@ impl App {
let mut p = new_path.clone(); let mut p = new_path.clone();
p.extend(var.path[old_path.len()..].iter().cloned()); p.extend(var.path[old_path.len()..].iter().cloned());
var.path = p; var.path = p;
var.status = crate::format::ItemStatus::Modified;
} }
} }
} }
@@ -229,6 +230,11 @@ impl App {
/// Transitions the application into Insert Mode for keys. /// Transitions the application into Insert Mode for keys.
pub fn enter_insert_key(&mut self) { pub fn enter_insert_key(&mut self) {
if !self.vars.is_empty() { 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.mode = Mode::InsertKey;
self.sync_input_with_selected(); self.sync_input_with_selected();
} }
@@ -331,24 +337,28 @@ impl App {
} }
/// Adds a new item relative to the selected item. /// 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() { if self.vars.is_empty() {
let new_key = "NEW_VAR".to_string(); let new_key = "NEW_VAR".to_string();
self.vars.push(ConfigItem { self.vars.push(ConfigItem {
key: new_key.clone(), key: new_key.clone(),
path: vec![PathSegment::Key(new_key)], path: vec![PathSegment::Key(new_key)],
value: Some("".to_string()), value: if is_group { None } else { Some("".to_string()) },
template_value: None, template_value: None,
default_value: None, default_value: None,
depth: 0, depth: 0,
is_group: false, is_group,
status: crate::format::ItemStatus::Modified, 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.selected = 0;
self.sync_input_with_selected(); self.sync_input_with_selected();
self.save_undo_state(); self.save_undo_state();
self.enter_insert(InsertVariant::Start); if is_group {
self.enter_insert_key();
} else {
self.enter_insert(InsertVariant::Start);
}
return; return;
} }
@@ -418,7 +428,7 @@ impl App {
} }
} else { } else {
let mut count = 1; 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(); let parent_path_slice = new_path.as_slice();
while self.vars.iter().any(|v| { while self.vars.iter().any(|v| {
@@ -426,7 +436,7 @@ impl App {
&& v.path.len() == parent_path_slice.len() + 1 && v.path.len() == parent_path_slice.len() + 1
&& v.key == candidate && v.key == candidate
}) { }) {
candidate = format!("NEW_VAR_{}", count); candidate = if is_group { format!("NEW_GROUP_{}", count) } else { format!("NEW_VAR_{}", count) };
count += 1; count += 1;
} }
new_path.push(PathSegment::Key(candidate.clone())); new_path.push(PathSegment::Key(candidate.clone()));
@@ -437,13 +447,13 @@ impl App {
let new_item = ConfigItem { let new_item = ConfigItem {
key: final_key, key: final_key,
path: new_path, path: new_path,
value: Some("".to_string()), value: if is_group { None } else { Some("".to_string()) },
template_value: None, template_value: None,
default_value: None, default_value: None,
depth: new_depth, depth: new_depth,
is_group: false, is_group,
status: crate::format::ItemStatus::Modified, 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); self.vars.insert(insert_pos, new_item);
@@ -458,6 +468,28 @@ impl App {
self.status_message = None; 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 /// Status bar helpers
pub fn selected_is_group(&self) -> bool { pub fn selected_is_group(&self) -> bool {
self.vars.get(self.selected).map(|v| v.is_group).unwrap_or(false) self.vars.get(self.selected).map(|v| v.is_group).unwrap_or(false)

View File

@@ -122,6 +122,9 @@ pub struct KeybindsConfig {
pub undo: String, pub undo: String,
pub redo: String, pub redo: String,
pub rename: String, pub rename: String,
pub append_group: String,
pub prepend_group: String,
pub toggle_group: String,
} }
impl Default for KeybindsConfig { impl Default for KeybindsConfig {
@@ -146,6 +149,9 @@ pub struct KeybindsConfig {
undo: "u".to_string(), undo: "u".to_string(),
redo: "U".to_string(), redo: "U".to_string(),
rename: "r".to_string(), rename: "r".to_string(),
append_group: "alt+o".to_string(),
prepend_group: "alt+O".to_string(),
toggle_group: "t".to_string(),
} }
} }
} }

View File

@@ -576,25 +576,43 @@ enabled = true
} }
#[test] #[test]
fn test_xml_flatten_unflatten() { fn test_group_rename_write() {
let xml_str = "<config><server><port>8080</port><enabled>true</enabled></server></config>";
let json_val = xml_to_json(xml_str).unwrap();
let mut vars = Vec::new(); 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()); let mut root = Value::Object(Map::new());
for var in vars { for var in &vars {
if !var.is_group { if !var.is_group {
insert_into_value(&mut root, &var.path, var.value.as_deref().unwrap_or(""), var.value_type); insert_into_value(&mut root, &var.path, var.value.as_deref().unwrap_or(""), var.value_type);
} }
} }
let unflattened_xml = json_to_xml(&root); let out = serde_json::to_string(&root).unwrap();
assert!(out.contains("\"new_group\""));
assert!(unflattened_xml.contains("<port>8080</port>")); assert!(out.contains("\"key\":\"val\""));
assert!(unflattened_xml.contains("<enabled>true</enabled>")); assert!(!out.contains("\"old_group\""));
assert!(unflattened_xml.contains("<config>") && unflattened_xml.contains("</config>"));
} }
} }

View File

@@ -80,15 +80,46 @@ mod tests {
use std::io::Write; use std::io::Write;
#[test] #[test]
fn test_parse_ini() { fn test_section_rename_write() {
let mut file = NamedTempFile::new().unwrap();
writeln!(file, "[server]\nport=8080\n[database]\nhost=localhost").unwrap();
let handler = IniHandler; 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,
}
];
assert!(vars.iter().any(|v| v.path_string() == "server" && v.is_group)); // Rename "server" to "srv"
assert!(vars.iter().any(|v| v.path_string() == "server.port" && v.value.as_deref() == Some("8080"))); vars[0].key = "srv".to_string();
assert!(vars.iter().any(|v| v.path_string() == "database.host" && v.value.as_deref() == Some("localhost"))); vars[0].path = vec![PathSegment::Key("srv".to_string())];
// 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]"));
} }
} }

View File

@@ -89,14 +89,45 @@ mod tests {
use std::io::Write; use std::io::Write;
#[test] #[test]
fn test_parse_properties() { fn test_group_rename_write() {
let mut file = NamedTempFile::new().unwrap();
writeln!(file, "server.port=8080\ndatabase.host=localhost").unwrap();
let handler = PropertiesHandler; 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,
}
];
assert!(vars.iter().any(|v| v.path_string() == "server" && v.is_group)); // Rename "server" to "srv"
assert!(vars.iter().any(|v| v.path_string() == "server.port" && v.value.as_deref() == Some("8080"))); vars[0].key = "srv".to_string();
vars[0].path = vec![PathSegment::Key("srv".to_string())];
// 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"));
} }
} }

View File

@@ -110,8 +110,19 @@ where
/// Handles primary navigation (j/k) and transitions to insert or command modes. /// Handles primary navigation (j/k) and transitions to insert or command modes.
fn handle_navigation_mode(&mut self, key: KeyEvent) -> io::Result<()> { fn handle_navigation_mode(&mut self, key: KeyEvent) -> io::Result<()> {
if let KeyCode::Char(c) = key.code { let key_str = if let KeyCode::Char(c) = key.code {
self.key_sequence.push(c); 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 // Collect all configured keybinds
let binds = [ let binds = [
@@ -131,6 +142,9 @@ where
(&self.config.keybinds.undo, "undo"), (&self.config.keybinds.undo, "undo"),
(&self.config.keybinds.redo, "redo"), (&self.config.keybinds.redo, "redo"),
(&self.config.keybinds.rename, "rename"), (&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"), (&"a".to_string(), "add_missing"),
(&":".to_string(), "command"), (&":".to_string(), "command"),
(&"q".to_string(), "quit"), (&"q".to_string(), "quit"),
@@ -165,12 +179,18 @@ where
"previous_match" => self.app.jump_previous_match(), "previous_match" => self.app.jump_previous_match(),
"jump_top" => self.app.jump_top(), "jump_top" => self.app.jump_top(),
"jump_bottom" => self.app.jump_bottom(), "jump_bottom" => self.app.jump_bottom(),
"append_item" => self.app.add_item(true), "append_item" => self.app.add_item(true, false),
"prepend_item" => self.app.add_item(false), "prepend_item" => self.app.add_item(false, false),
"delete_item" => self.app.delete_selected(), "delete_item" => self.app.delete_selected(),
"undo" => self.app.undo(), "undo" => self.app.undo(),
"redo" => self.app.redo(), "redo" => self.app.redo(),
"rename" => self.app.enter_insert_key(), "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" => { "add_missing" => {
self.add_missing_item(); self.add_missing_item();
} }
@@ -182,9 +202,8 @@ where
_ => {} _ => {}
} }
} else if !prefix_match { } 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.clear();
self.key_sequence.push(c); self.key_sequence.push_str(&key_str);
} }
} else { } else {
// Non-character keys reset the sequence buffer // Non-character keys reset the sequence buffer

View File

@@ -238,7 +238,7 @@ pub fn draw(f: &mut Frame, app: &mut App, config: &Config) {
f.render_widget(input, chunks[2]); f.render_widget(input, chunks[2]);
// Position the terminal cursor correctly when in Insert mode. // 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( f.set_cursor_position(ratatui::layout::Position::new(
chunks[2].x + 1 + cursor_pos as u16, chunks[2].x + 1 + cursor_pos as u16,
chunks[2].y + 1, 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!("{}/{}/{} edit", kb.edit, kb.edit_append, kb.edit_substitute));
} }
parts.push(format!("{} rename", kb.rename)); parts.push(format!("{} rename", kb.rename));
parts.push(format!("{} toggle", kb.toggle_group));
if app.selected_is_missing() { if app.selected_is_missing() {
parts.push(format!("{} add", "a")); // 'a' is currently hardcoded in runner parts.push(format!("{} add", "a")); // 'a' is currently hardcoded in runner
} }
if app.selected_is_array() { if app.selected_is_array() {
parts.push(format!("{}/{} array", kb.append_item, kb.prepend_item)); 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!("{} del", kb.delete_item));
parts.push(format!("{} undo", kb.undo)); parts.push(format!("{} undo", kb.undo));