Version Check / check-version (pull_request) Successful in 10s
The headline flow — fill a config from its template — did not work. Three bugs, each individually enough to make the tool feel unfinished: - `add_missing` was unreachable. `find_binding` returned the first match in a linear array and `edit_append` was also bound to `a`, 17 entries earlier. Bindings now resolve through one table built from the config, and `KeybindsConfig::conflicts()` reports any key bound twice instead of letting one silently shadow the other. `add_missing` moves to `<Space>`, with `A` to take every missing default at once. - Item status was ignored on write. Every writer fell back to `template_value`, so "(missing)" keys landed in the output regardless and saving was close to a copy. Unaccepted entries are now omitted. - Parse errors were swallowed, then the good file was overwritten. A malformed target opened as an empty editor and `:w` truncated it. Parse failures are now fatal, reported with context, before the TUI starts. Also: - Split "differs from the template" (ItemStatus::Overridden) from "you edited this" (ConfigItem::dirty). A real config differs from its example on nearly every line, so the old Modified flag painted the whole list orange and carried no signal. - Splice template-only entries in at their natural position instead of appending them, so a missing nested key no longer strands itself at the bottom of the tree at depth 3. - Preserve .env comments and blank lines across a save. - Header naming the output file and the template it is compared against, plus missing/changed counts. Previously nothing on screen said which files were in play or where `:w` would write. - `?` opens a two-column keybinding reference built from the user's own config, scrollable on short terminals. The status bar drops to a few contextual hints; the old one joined 11+ hints into an unwrapped line that truncated at 80 columns. - Foldable groups, which the `+ ` prefix had always implied. - Search covers values as well as keys, reports `match n/m`, and `Esc` restores the pre-search cursor. - Quitting with unsaved changes is refused; added `:q!`, `:x`, `:w <path>`. - Status messages expire after 4s instead of permanently replacing hints. - Cancelling a rename on a just-created item removes it again. - Editing a Number field with text demotes the type instead of failing obscurely at write time. - Undo-tree branches are reachable via `g-`/`g+` and reported on undo. - Poll instead of blocking, so resizes repaint; drop the unused mouse capture that was costing terminal text selection for nothing. - `--format` accepts xml, ini and properties, which were already supported. 36 tests, up from 13. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01C5mrbEFmwv2A4cdzbcSiS1
303 lines
10 KiB
Rust
303 lines
10 KiB
Rust
use crate::format::ConfigItem;
|
|
use std::collections::HashMap;
|
|
|
|
/// Represents a single snapshot of the application state for undo/redo purposes.
|
|
pub struct EditAction {
|
|
/// The complete list of configuration items at the time of the action.
|
|
pub state: Vec<ConfigItem>,
|
|
/// The index of the item that was selected during this action.
|
|
pub selected: usize,
|
|
}
|
|
|
|
/// A node in the undo tree, representing a point in the application's history.
|
|
pub struct UndoNode {
|
|
/// The state data captured at this history point.
|
|
pub action: EditAction,
|
|
/// ID of the parent node (previous state). Root node has None.
|
|
pub parent: Option<usize>,
|
|
/// IDs of all states that branched off from this one.
|
|
pub children: Vec<usize>,
|
|
}
|
|
|
|
/// A non-linear undo/redo system that tracks history as a branching tree.
|
|
///
|
|
/// Unlike a simple stack, an UndoTree allows users to undo several steps,
|
|
/// make a new change (creating a branch), and still navigate through
|
|
/// the most recent history path.
|
|
pub struct UndoTree {
|
|
/// Map of node IDs to their respective history nodes.
|
|
nodes: HashMap<usize, UndoNode>,
|
|
/// The ID of the node representing the current application state.
|
|
current_node: usize,
|
|
/// Counter for assigning unique IDs to new nodes.
|
|
next_id: usize,
|
|
/// Tracks the most recently active branch for each node.
|
|
/// This allows the 'redo' operation to follow the path the user
|
|
/// actually took when multiple branches exist.
|
|
latest_branch: HashMap<usize, usize>,
|
|
}
|
|
|
|
impl UndoTree {
|
|
/// Creates a new undo tree initialized with the starting application state.
|
|
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(),
|
|
}
|
|
}
|
|
|
|
/// Pushes a new state onto the tree, branching off from the current node.
|
|
///
|
|
/// This creates a new child node for the current position and updates
|
|
/// the branch tracking to ensure this new path is preferred during redo.
|
|
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;
|
|
}
|
|
|
|
/// Moves the current pointer back to the parent node and returns the previous state.
|
|
/// Returns None if the current node is the root (no more history to undo).
|
|
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
|
|
}
|
|
|
|
/// Moves the current pointer forward to the latest child branch and returns the state.
|
|
///
|
|
/// Redo follows the `latest_branch` map to decide which path to take
|
|
/// if multiple branches exist. If no branch is recorded, it defaults to the
|
|
/// most recently created child.
|
|
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
|
|
let current_id = self.current_node;
|
|
if let Some(current) = self.nodes.get(¤t_id)
|
|
&& let Some(&first_child_id) = current.children.last() {
|
|
self.current_node = first_child_id;
|
|
self.latest_branch.insert(current_id, first_child_id);
|
|
return self.nodes.get(&first_child_id).map(|n| &n.action);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Discards the current node, moving back to its parent.
|
|
///
|
|
/// Used when an action is rolled back before the user ever saw it as a
|
|
/// distinct step, such as a newly added item abandoned at the rename prompt.
|
|
pub fn pop(&mut self) {
|
|
let Some(current) = self.nodes.get(&self.current_node) else { return };
|
|
let Some(parent_id) = current.parent else { return };
|
|
|
|
let removed = self.current_node;
|
|
self.nodes.remove(&removed);
|
|
if let Some(parent) = self.nodes.get_mut(&parent_id) {
|
|
parent.children.retain(|&c| c != removed);
|
|
}
|
|
if self.latest_branch.get(&parent_id) == Some(&removed) {
|
|
match self.nodes.get(&parent_id).and_then(|p| p.children.last()) {
|
|
Some(&fallback) => {
|
|
self.latest_branch.insert(parent_id, fallback);
|
|
}
|
|
None => {
|
|
self.latest_branch.remove(&parent_id);
|
|
}
|
|
}
|
|
}
|
|
self.latest_branch.remove(&removed);
|
|
self.current_node = parent_id;
|
|
}
|
|
|
|
/// Which forward branch redo would take, as `(index, total)`, both 1-based.
|
|
///
|
|
/// `None` when the current node has no children at all.
|
|
pub fn branch_position(&self) -> Option<(usize, usize)> {
|
|
let current = self.nodes.get(&self.current_node)?;
|
|
if current.children.is_empty() {
|
|
return None;
|
|
}
|
|
let preferred = self
|
|
.latest_branch
|
|
.get(&self.current_node)
|
|
.copied()
|
|
.or_else(|| current.children.last().copied())?;
|
|
let index = current
|
|
.children
|
|
.iter()
|
|
.position(|&c| c == preferred)
|
|
.unwrap_or(0);
|
|
Some((index + 1, current.children.len()))
|
|
}
|
|
|
|
/// Picks a different forward branch at the current point.
|
|
///
|
|
/// Returns the new `(index, total)` position, or `None` when there is no
|
|
/// alternative history to choose from.
|
|
pub fn cycle_branch(&mut self, forward: bool) -> Option<(usize, usize)> {
|
|
let (index, total) = self.branch_position()?;
|
|
if total < 2 {
|
|
return None;
|
|
}
|
|
let next = if forward {
|
|
index % total
|
|
} else {
|
|
(index + total - 2) % total
|
|
};
|
|
let child = self.nodes.get(&self.current_node)?.children[next];
|
|
self.latest_branch.insert(self.current_node, child);
|
|
Some((next + 1, total))
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::format::ValueType;
|
|
|
|
fn dummy_item(key: &str) -> ConfigItem {
|
|
ConfigItem::leaf(key, vec![], key, 0, 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);
|
|
|
|
// Redo -> State 3
|
|
let action = tree.redo().unwrap();
|
|
assert_eq!(action.state[0].key, "C");
|
|
assert_eq!(action.selected, 2);
|
|
|
|
// Branching: Undo twice to State 1
|
|
tree.undo();
|
|
tree.undo();
|
|
|
|
// Push State 4 (from State 1)
|
|
let state4 = vec![dummy_item("D")];
|
|
tree.push(state4.clone(), 3);
|
|
|
|
// Undo -> State 1
|
|
let action = tree.undo().unwrap();
|
|
assert_eq!(action.state[0].key, "A");
|
|
|
|
// Redo -> State 4 (follows latest branch D, not old branch B)
|
|
let action = tree.redo().unwrap();
|
|
assert_eq!(action.state[0].key, "D");
|
|
}
|
|
|
|
#[test]
|
|
fn test_redo_fallback_fix() {
|
|
let state1 = vec![dummy_item("A")];
|
|
let mut tree = UndoTree::new(state1.clone(), 0);
|
|
|
|
let state2 = vec![dummy_item("B")];
|
|
tree.push(state2.clone(), 1);
|
|
|
|
tree.undo();
|
|
// Redo should move to state 2
|
|
let action = tree.redo().unwrap();
|
|
assert_eq!(action.state[0].key, "B");
|
|
|
|
// Calling redo again should NOT change the current node or returned action
|
|
// (since it's already at the latest child)
|
|
assert!(tree.redo().is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_pop_discards_current_state() {
|
|
let mut tree = UndoTree::new(vec![dummy_item("A")], 0);
|
|
tree.push(vec![dummy_item("B")], 1);
|
|
|
|
tree.pop();
|
|
|
|
// Back at A, with nothing left to redo into.
|
|
assert!(tree.redo().is_none());
|
|
assert!(tree.undo().is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_branch_cycling() {
|
|
let mut tree = UndoTree::new(vec![dummy_item("A")], 0);
|
|
tree.push(vec![dummy_item("B")], 0);
|
|
tree.undo();
|
|
tree.push(vec![dummy_item("C")], 0);
|
|
tree.undo();
|
|
|
|
// Two futures hang off the root; redo prefers the newest.
|
|
assert_eq!(tree.branch_position(), Some((2, 2)));
|
|
assert_eq!(tree.redo().unwrap().state[0].key, "C");
|
|
|
|
tree.undo();
|
|
tree.cycle_branch(true);
|
|
assert_eq!(tree.redo().unwrap().state[0].key, "B");
|
|
}
|
|
}
|