fix: make the template workflow real, and the UI legible
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:
nvrlandClaude Opus 5 committed 2026-09-05 05:01:39 +02:00
1 parent 2e974e0503
commit e8634c17a2
16 files changed
+2218 -915

No files matched your search

+98 -12
View File
@@ -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");
}
}