fix: make the template workflow real, and the UI legible
Version Check / check-version (pull_request) Successful in 10s
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
This commit is contained in:
1 parent
2e974e0503
commit
e8634c17a2
16 files changed
+2218
-915
No files matched your search
+98
-12
@@ -122,25 +122,82 @@ impl UndoTree {
|
||||
}
|
||||
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::{ItemStatus, ValueType};
|
||||
use crate::format::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,
|
||||
}
|
||||
ConfigItem::leaf(key, vec![], key, 0, ValueType::String)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -213,4 +270,33 @@ mod tests {
|
||||
// (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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user