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
6 changed files with 197 additions and 19 deletions
Showing only changes of commit ca7ebc9563 - Show all commits

View File

@@ -1,5 +1,6 @@
use crate::format::{ConfigItem, PathSegment}; use crate::format::{ConfigItem, PathSegment};
use tui_input::Input; use tui_input::Input;
use crate::undo::UndoTree;
/// Represents the current operating mode of the application. /// Represents the current operating mode of the application.
pub enum Mode { pub enum Mode {
@@ -33,14 +34,15 @@ pub struct App {
pub input: Input, pub input: Input,
/// The current search query for filtering keys. /// The current search query for filtering keys.
pub search_query: String, pub search_query: String,
/// Stack of previous variable states for undo functionality. /// Undo history structured as a tree
pub undo_stack: Vec<Vec<ConfigItem>>, pub undo_tree: UndoTree,
} }
impl App { impl App {
/// Initializes a new application instance with the provided variables. /// Initializes a new application instance with the provided variables.
pub fn new(vars: Vec<ConfigItem>) -> Self { pub fn new(vars: Vec<ConfigItem>) -> Self {
let initial_input = vars.first().and_then(|v| v.value.clone()).unwrap_or_default(); let initial_input = vars.first().and_then(|v| v.value.clone()).unwrap_or_default();
let undo_tree = UndoTree::new(vars.clone(), 0);
Self { Self {
vars, vars,
selected: 0, selected: 0,
@@ -49,7 +51,7 @@ impl App {
status_message: None, status_message: None,
input: Input::new(initial_input), input: Input::new(initial_input),
search_query: String::new(), search_query: String::new(),
undo_stack: Vec::new(), undo_tree,
} }
} }
@@ -325,18 +327,16 @@ impl App {
.unwrap_or(false) .unwrap_or(false)
} }
/// Saves the current state of variables to the undo stack. /// Saves the current state of variables to the undo tree.
pub fn save_undo_state(&mut self) { pub fn save_undo_state(&mut self) {
self.undo_stack.push(self.vars.clone()); self.undo_tree.push(self.vars.clone(), self.selected);
if self.undo_stack.len() > 50 {
self.undo_stack.remove(0);
}
} }
/// Reverts to the last saved state of variables. /// Reverts to the previous state in the undo tree.
pub fn undo(&mut self) { pub fn undo(&mut self) {
if let Some(previous_vars) = self.undo_stack.pop() { if let Some(action) = self.undo_tree.undo() {
self.vars = previous_vars; self.vars = action.state.clone();
self.selected = action.selected;
if self.selected >= self.vars.len() && !self.vars.is_empty() { if self.selected >= self.vars.len() && !self.vars.is_empty() {
self.selected = self.vars.len() - 1; self.selected = self.vars.len() - 1;
} }
@@ -346,4 +346,19 @@ impl App {
self.status_message = Some("Nothing to undo".to_string()); self.status_message = Some("Nothing to undo".to_string());
} }
} }
/// Advances to the next state in the undo tree.
pub fn redo(&mut self) {
if let Some(action) = self.undo_tree.redo() {
self.vars = action.state.clone();
self.selected = action.selected;
if self.selected >= self.vars.len() && !self.vars.is_empty() {
self.selected = self.vars.len() - 1;
}
self.sync_input_with_selected();
self.status_message = Some("Redo applied".to_string());
} else {
self.status_message = Some("Nothing to redo".to_string());
}
}
} }

View File

@@ -120,6 +120,7 @@ pub struct KeybindsConfig {
pub prepend_item: String, pub prepend_item: String,
pub delete_item: String, pub delete_item: String,
pub undo: String, pub undo: String,
pub redo: String,
} }
impl Default for KeybindsConfig { impl Default for KeybindsConfig {
@@ -128,8 +129,8 @@ impl Default for KeybindsConfig {
down: "j".to_string(), down: "j".to_string(),
up: "k".to_string(), up: "k".to_string(),
edit: "i".to_string(), edit: "i".to_string(),
edit_append: "A".to_string(), edit_append: "a".to_string(),
edit_substitute: "S".to_string(), edit_substitute: "s".to_string(),
save: ":w".to_string(), save: ":w".to_string(),
quit: ":q".to_string(), quit: ":q".to_string(),
normal_mode: "Esc".to_string(), normal_mode: "Esc".to_string(),
@@ -142,6 +143,7 @@ impl Default for KeybindsConfig {
prepend_item: "O".to_string(), prepend_item: "O".to_string(),
delete_item: "dd".to_string(), delete_item: "dd".to_string(),
undo: "u".to_string(), undo: "u".to_string(),
redo: "U".to_string(),
} }
} }
} }

View File

@@ -125,14 +125,13 @@ fn json_to_xml(value: &Value) -> String {
let mut writer = Writer::new_with_indent(Vec::new(), b' ', 4); let mut writer = Writer::new_with_indent(Vec::new(), b' ', 4);
fn write_recursive(writer: &mut Writer<Vec<u8>>, value: &Value, key_name: Option<&str>) { fn write_recursive(writer: &mut Writer<Vec<u8>>, value: &Value, key_name: Option<&str>) {
if let Some(k) = key_name { if let Some(k) = key_name
if k == "$text" { && k == "$text" {
if let Some(s) = value.as_str() { if let Some(s) = value.as_str() {
writer.write_event(Event::Text(BytesText::new(s))).unwrap(); writer.write_event(Event::Text(BytesText::new(s))).unwrap();
} }
return; return;
} }
}
match value { match value {
Value::Object(map) => { Value::Object(map) => {

View File

@@ -6,6 +6,7 @@ mod format;
mod runner; mod runner;
mod ui; mod ui;
mod resolver; mod resolver;
mod undo;
use app::App; use app::App;
use config::load_config; use config::load_config;

View File

@@ -128,6 +128,7 @@ where
(&self.config.keybinds.prepend_item, "prepend_item"), (&self.config.keybinds.prepend_item, "prepend_item"),
(&self.config.keybinds.delete_item, "delete_item"), (&self.config.keybinds.delete_item, "delete_item"),
(&self.config.keybinds.undo, "undo"), (&self.config.keybinds.undo, "undo"),
(&self.config.keybinds.redo, "redo"),
(&"a".to_string(), "add_missing"), (&"a".to_string(), "add_missing"),
(&":".to_string(), "command"), (&":".to_string(), "command"),
(&"q".to_string(), "quit"), (&"q".to_string(), "quit"),
@@ -166,6 +167,7 @@ where
"prepend_item" => self.app.add_array_item(false), "prepend_item" => self.app.add_array_item(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(),
"add_missing" => { "add_missing" => {
self.app.save_undo_state(); self.app.save_undo_state();
self.add_missing_item(); self.add_missing_item();

159
src/undo.rs Normal file
View File

@@ -0,0 +1,159 @@
use crate::format::ConfigItem;
use std::collections::HashMap;
pub struct EditAction {
pub state: Vec<ConfigItem>,
pub selected: usize,
}
pub struct UndoNode {
pub action: EditAction,
pub parent: Option<usize>,
pub children: Vec<usize>,
}
pub struct UndoTree {
nodes: HashMap<usize, UndoNode>,
current_node: usize,
next_id: usize,
// Track the latest child added to a node to know which branch to follow on redo
latest_branch: HashMap<usize, usize>,
}
impl UndoTree {
pub fn new(initial_state: Vec<ConfigItem>, initial_selected: usize) -> Self {
let root_id = 0;
let root_node = UndoNode {
action: EditAction {
state: initial_state,
selected: initial_selected,
},
parent: None,
children: Vec::new(),
};
let mut nodes = HashMap::new();
nodes.insert(root_id, root_node);
Self {
nodes,
current_node: root_id,
next_id: 1,
latest_branch: HashMap::new(),
}
}
pub fn push(&mut self, state: Vec<ConfigItem>, selected: usize) {
let new_id = self.next_id;
self.next_id += 1;
let new_node = UndoNode {
action: EditAction { state, selected },
parent: Some(self.current_node),
children: Vec::new(),
};
// Add to nodes
self.nodes.insert(new_id, new_node);
// Update parent's children
if let Some(parent_node) = self.nodes.get_mut(&self.current_node) {
parent_node.children.push(new_id);
}
// Record this as the latest branch for the parent
self.latest_branch.insert(self.current_node, new_id);
// Move current pointer
self.current_node = new_id;
}
pub fn undo(&mut self) -> Option<&EditAction> {
if let Some(current) = self.nodes.get(&self.current_node)
&& let Some(parent_id) = current.parent {
self.current_node = parent_id;
return self.nodes.get(&parent_id).map(|n| &n.action);
}
None
}
pub fn redo(&mut self) -> Option<&EditAction> {
if let Some(next_id) = self.latest_branch.get(&self.current_node).copied() {
self.current_node = next_id;
return self.nodes.get(&next_id).map(|n| &n.action);
} else {
// Fallback: if there is no recorded latest branch but there are children
if let Some(current) = self.nodes.get(&self.current_node)
&& let Some(&first_child_id) = current.children.last() {
self.current_node = first_child_id;
self.latest_branch.insert(self.current_node, first_child_id);
return self.nodes.get(&first_child_id).map(|n| &n.action);
}
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::format::{ItemStatus, ValueType};
fn dummy_item(key: &str) -> ConfigItem {
ConfigItem {
key: key.to_string(),
path: vec![],
value: Some(key.to_string()),
template_value: None,
default_value: None,
depth: 0,
is_group: false,
status: ItemStatus::Present,
value_type: ValueType::String,
}
}
#[test]
fn test_undo_redo_tree() {
let state1 = vec![dummy_item("A")];
let mut tree = UndoTree::new(state1.clone(), 0);
// Push state 2
let state2 = vec![dummy_item("B")];
tree.push(state2.clone(), 1);
// Push state 3
let state3 = vec![dummy_item("C")];
tree.push(state3.clone(), 2);
// Undo -> State 2
let action = tree.undo().unwrap();
assert_eq!(action.state[0].key, "B");
assert_eq!(action.selected, 1);
// Undo -> State 1
let action = tree.undo().unwrap();
assert_eq!(action.state[0].key, "A");
assert_eq!(action.selected, 0);
// Undo again -> None (already at root)
assert!(tree.undo().is_none());
// Redo -> State 2
let action = tree.redo().unwrap();
assert_eq!(action.state[0].key, "B");
assert_eq!(action.selected, 1);
// Branching: Push State 4 (from State 2)
let state4 = vec![dummy_item("D")];
tree.push(state4.clone(), 3);
// Undo -> State 2
let action = tree.undo().unwrap();
assert_eq!(action.state[0].key, "B");
// Redo -> State 4 (follows latest branch D, not old branch C)
let action = tree.redo().unwrap();
assert_eq!(action.state[0].key, "D");
}
}