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
+230
-220
@@ -1,18 +1,22 @@
|
||||
//! Orchestrates the main application execution, terminal events, and TUI rendering.
|
||||
//!
|
||||
//! The `AppRunner` is responsible for the event loop, intercepting raw
|
||||
//! keyboard input, and translating it into high-level application actions.
|
||||
//! The `AppRunner` owns the event loop, turns raw key events into the
|
||||
//! [`Action`]s declared in the user's config, and runs the `:` command line.
|
||||
|
||||
use crate::app::{App, InsertVariant, Mode};
|
||||
use crate::config::Config;
|
||||
use crate::config::{Action, Config};
|
||||
use crate::format::FormatHandler;
|
||||
use crossterm::event::{self, Event, KeyCode, KeyEvent};
|
||||
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::Backend;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use tui_input::backend::crossterm::EventHandler;
|
||||
|
||||
/// How often the loop wakes up with no input, so timed UI state can settle.
|
||||
const TICK: Duration = Duration::from_millis(250);
|
||||
|
||||
/// Manages the main application execution loop, event handling, and terminal interaction.
|
||||
pub struct AppRunner<'a, B: Backend> {
|
||||
/// Reference to the terminal instance.
|
||||
@@ -21,10 +25,10 @@ pub struct AppRunner<'a, B: Backend> {
|
||||
app: &'a mut App,
|
||||
/// Loaded user configuration (keybinds, theme).
|
||||
config: &'a Config,
|
||||
/// Path where the final configuration will be saved.
|
||||
output_path: &'a Path,
|
||||
/// Handler for the specific file format (env, json, yaml, toml).
|
||||
handler: &'a dyn FormatHandler,
|
||||
/// Every key sequence and the action it triggers, resolved from the config once.
|
||||
bindings: Vec<(String, Action)>,
|
||||
/// Buffer for storing active command entry (e.g., ":w").
|
||||
command_buffer: String,
|
||||
/// Buffer for storing multi-key sequence of presses (e.g., "gg").
|
||||
@@ -40,15 +44,14 @@ where
|
||||
terminal: &'a mut Terminal<B>,
|
||||
app: &'a mut App,
|
||||
config: &'a Config,
|
||||
output_path: &'a Path,
|
||||
handler: &'a dyn FormatHandler,
|
||||
) -> Self {
|
||||
Self {
|
||||
terminal,
|
||||
app,
|
||||
config,
|
||||
output_path,
|
||||
handler,
|
||||
bindings: config.keybinds.resolved(),
|
||||
command_buffer: String::new(),
|
||||
key_sequence: String::new(),
|
||||
}
|
||||
@@ -56,15 +59,25 @@ where
|
||||
|
||||
/// Starts the main application loop.
|
||||
///
|
||||
/// This loop continues until `self.app.running` is set to false.
|
||||
/// Each iteration draws the UI and waits for a keyboard event.
|
||||
/// This loop continues until `self.app.running` is set to false. It polls
|
||||
/// rather than blocking so that a resize or an expiring status message
|
||||
/// repaints without waiting for the next keystroke.
|
||||
pub fn run(&mut self) -> io::Result<()> {
|
||||
while self.app.running {
|
||||
self.terminal
|
||||
.draw(|f| crate::ui::draw(f, self.app, self.config))?;
|
||||
|
||||
if let Event::Key(key) = event::read()? {
|
||||
self.handle_key_event(key)?;
|
||||
if !event::poll(TICK)? {
|
||||
continue;
|
||||
}
|
||||
|
||||
match event::read()? {
|
||||
// Key *releases* are delivered on some terminals; acting on both
|
||||
// edges would fire every binding twice.
|
||||
Event::Key(key) if key.kind != KeyEventKind::Release => {
|
||||
self.handle_key_event(key)?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -77,12 +90,27 @@ where
|
||||
fn handle_key_event(&mut self, key: KeyEvent) -> io::Result<()> {
|
||||
match self.app.mode {
|
||||
Mode::Normal => self.handle_normal_mode(key),
|
||||
Mode::Insert => self.handle_insert_mode(key),
|
||||
Mode::InsertKey => self.handle_insert_key_mode(key),
|
||||
Mode::Insert | Mode::InsertKey => self.handle_insert_mode(key),
|
||||
Mode::Search => self.handle_search_mode(key),
|
||||
Mode::Help => self.handle_help_mode(key),
|
||||
}
|
||||
}
|
||||
|
||||
/// The help overlay scrolls with the usual movement keys; anything else closes it.
|
||||
fn handle_help_mode(&mut self, key: KeyEvent) -> io::Result<()> {
|
||||
let token = Self::key_token(key);
|
||||
let is = |action| token.as_deref() == Some(self.config.keybinds.key_for(action)).as_deref();
|
||||
|
||||
if is(Action::Down) || key.code == KeyCode::Down {
|
||||
self.app.scroll_help(true);
|
||||
} else if is(Action::Up) || key.code == KeyCode::Up {
|
||||
self.app.scroll_help(false);
|
||||
} else {
|
||||
self.app.toggle_help();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handles keys in Normal mode, separating navigation from command entry.
|
||||
fn handle_normal_mode(&mut self, key: KeyEvent) -> io::Result<()> {
|
||||
if !self.command_buffer.is_empty() {
|
||||
@@ -98,204 +126,156 @@ where
|
||||
KeyCode::Enter => {
|
||||
let cmd = self.command_buffer.clone();
|
||||
self.command_buffer.clear();
|
||||
self.execute_command(&cmd)
|
||||
self.app.prompt = None;
|
||||
self.execute_command(cmd.trim())
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
self.command_buffer.clear();
|
||||
self.app.status_message = None;
|
||||
self.app.prompt = None;
|
||||
self.app.clear_status();
|
||||
Ok(())
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
self.command_buffer.pop();
|
||||
self.sync_command_status();
|
||||
self.sync_command_prompt();
|
||||
Ok(())
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
self.command_buffer.push(c);
|
||||
self.sync_command_status();
|
||||
self.sync_command_prompt();
|
||||
Ok(())
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders a key event as the string used in the keybinding config.
|
||||
///
|
||||
/// Named keys are spelled in angle brackets (`<Space>`) so that they can
|
||||
/// never be confused with a sequence of ordinary characters.
|
||||
fn key_token(key: KeyEvent) -> Option<String> {
|
||||
let base = match key.code {
|
||||
KeyCode::Char(' ') => "<Space>".to_string(),
|
||||
KeyCode::Char(c) => c.to_string(),
|
||||
KeyCode::Tab => "<Tab>".to_string(),
|
||||
KeyCode::BackTab => "<S-Tab>".to_string(),
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
if key.modifiers.contains(KeyModifiers::ALT) {
|
||||
Some(format!("alt+{}", base))
|
||||
} else {
|
||||
Some(base)
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles primary navigation and transitions to insert or command modes.
|
||||
///
|
||||
/// This method manages multi-key sequences (like `gg`) and immediate
|
||||
/// actions (like `i`). It correctly re-evaluates sequences to prevent
|
||||
/// "one-key-behind" responsiveness bugs.
|
||||
fn handle_navigation_mode(&mut self, key: KeyEvent) -> io::Result<()> {
|
||||
let key_str = if let KeyCode::Char(c) = key.code {
|
||||
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);
|
||||
|
||||
let mut match_result = self.find_binding();
|
||||
if match_result.is_none() {
|
||||
if self.is_prefix_binding() {
|
||||
// It's a prefix for a multi-key bind (like first 'g' in 'gg'), wait for more.
|
||||
return Ok(());
|
||||
} else {
|
||||
// Not a match and not a prefix, restart the buffer with the current key.
|
||||
self.key_sequence.clear();
|
||||
self.key_sequence.push_str(&key_str);
|
||||
match_result = self.find_binding();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(action) = match_result {
|
||||
self.key_sequence.clear();
|
||||
match action {
|
||||
"down" => self.app.next(),
|
||||
"up" => self.app.previous(),
|
||||
"edit" => self.app.enter_insert(InsertVariant::Start),
|
||||
"edit_append" => self.app.enter_insert(InsertVariant::End),
|
||||
"edit_substitute" => self.app.enter_insert(InsertVariant::Substitute),
|
||||
"search" => {
|
||||
self.app.mode = Mode::Search;
|
||||
self.app.search_query.clear();
|
||||
self.app.status_message = Some(format!("{} ", self.config.keybinds.search));
|
||||
}
|
||||
"next_match" => self.app.jump_next_match(),
|
||||
"previous_match" => self.app.jump_previous_match(),
|
||||
"jump_top" => self.app.jump_top(),
|
||||
"jump_bottom" => self.app.jump_bottom(),
|
||||
"append_item" => self.app.add_item(true, false, false),
|
||||
"prepend_item" => self.app.add_item(false, false, false),
|
||||
"delete_item" => self.app.delete_selected(),
|
||||
"undo" => self.app.undo(),
|
||||
"redo" => self.app.redo(),
|
||||
"rename" => self.app.enter_insert_key(),
|
||||
"append_group" => self.app.add_item(true, true, true),
|
||||
"prepend_group" => self.app.add_item(false, true, true),
|
||||
"toggle_group" => {
|
||||
self.app.toggle_group_selected();
|
||||
self.app.save_undo_state();
|
||||
}
|
||||
"add_missing" => {
|
||||
self.add_missing_item();
|
||||
}
|
||||
"command" => {
|
||||
self.command_buffer.push(':');
|
||||
self.sync_command_status();
|
||||
}
|
||||
"quit" => self.app.running = false,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Reset the sequence buffer if a non-character key (like Arrow Keys) is pressed.
|
||||
let Some(token) = Self::key_token(key) else {
|
||||
// Reset the sequence buffer if a non-character key is pressed.
|
||||
self.key_sequence.clear();
|
||||
match key.code {
|
||||
KeyCode::Down => self.app.next(),
|
||||
KeyCode::Up => self.app.previous(),
|
||||
KeyCode::Enter => self.save_file()?,
|
||||
KeyCode::Esc => self.app.status_message = None,
|
||||
KeyCode::Enter => {
|
||||
self.save_file(None);
|
||||
}
|
||||
KeyCode::Esc => self.app.clear_status(),
|
||||
_ => {}
|
||||
}
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
self.key_sequence.push_str(&token);
|
||||
|
||||
let mut action = self.find_binding();
|
||||
if action.is_none() {
|
||||
if self.is_prefix_binding() {
|
||||
// A partial multi-key bind (like the first 'g' of 'gg'): wait.
|
||||
return Ok(());
|
||||
}
|
||||
// Not a match and not a prefix: restart from the current key.
|
||||
self.key_sequence = token;
|
||||
action = self.find_binding();
|
||||
if action.is_none() && !self.is_prefix_binding() {
|
||||
self.key_sequence.clear();
|
||||
}
|
||||
}
|
||||
|
||||
let Some(action) = action else { return Ok(()) };
|
||||
self.key_sequence.clear();
|
||||
self.dispatch(action);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Looks up the current `key_sequence` in the configured keybindings.
|
||||
fn find_binding(&self) -> Option<&'static str> {
|
||||
let binds = [
|
||||
(&self.config.keybinds.down, "down"),
|
||||
(&self.config.keybinds.up, "up"),
|
||||
(&self.config.keybinds.edit, "edit"),
|
||||
(&self.config.keybinds.edit_append, "edit_append"),
|
||||
(&self.config.keybinds.edit_substitute, "edit_substitute"),
|
||||
(&"S".to_string(), "edit_substitute"), // Semantic alias for Vim compatibility.
|
||||
(&self.config.keybinds.search, "search"),
|
||||
(&self.config.keybinds.next_match, "next_match"),
|
||||
(&self.config.keybinds.previous_match, "previous_match"),
|
||||
(&self.config.keybinds.jump_top, "jump_top"),
|
||||
(&self.config.keybinds.jump_bottom, "jump_bottom"),
|
||||
(&self.config.keybinds.append_item, "append_item"),
|
||||
(&self.config.keybinds.prepend_item, "prepend_item"),
|
||||
(&self.config.keybinds.delete_item, "delete_item"),
|
||||
(&self.config.keybinds.undo, "undo"),
|
||||
(&self.config.keybinds.redo, "redo"),
|
||||
(&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"),
|
||||
(&":".to_string(), "command"),
|
||||
(&"q".to_string(), "quit"),
|
||||
];
|
||||
|
||||
for (bind, action) in binds.iter() {
|
||||
if bind == &&self.key_sequence {
|
||||
return Some(*action);
|
||||
/// Applies a resolved action to the application state.
|
||||
fn dispatch(&mut self, action: Action) {
|
||||
match action {
|
||||
Action::Down => self.app.next(),
|
||||
Action::Up => self.app.previous(),
|
||||
Action::JumpTop => self.app.jump_top(),
|
||||
Action::JumpBottom => self.app.jump_bottom(),
|
||||
Action::Edit => self.app.enter_insert(InsertVariant::Start),
|
||||
Action::EditAppend => self.app.enter_insert(InsertVariant::End),
|
||||
Action::EditSubstitute => self.app.enter_insert(InsertVariant::Substitute),
|
||||
Action::Rename => self.app.enter_insert_key(),
|
||||
Action::Search => {
|
||||
self.app.begin_search();
|
||||
self.sync_search_prompt();
|
||||
}
|
||||
Action::NextMatch => self.app.jump_next_match(),
|
||||
Action::PreviousMatch => self.app.jump_previous_match(),
|
||||
Action::AppendItem => self.app.add_item(true, false, false),
|
||||
Action::PrependItem => self.app.add_item(false, false, false),
|
||||
Action::AppendGroup => self.app.add_item(true, true, true),
|
||||
Action::PrependGroup => self.app.add_item(false, true, true),
|
||||
Action::DeleteItem => self.app.delete_selected(),
|
||||
Action::ToggleGroup => {
|
||||
self.app.toggle_group_selected();
|
||||
self.app.save_undo_state();
|
||||
}
|
||||
Action::AddMissing => self.app.accept_selected(),
|
||||
Action::AcceptAll => self.app.accept_all_missing(),
|
||||
Action::ToggleFold => self.app.toggle_fold(),
|
||||
Action::FoldAll => self.app.set_all_folds(true),
|
||||
Action::UnfoldAll => self.app.set_all_folds(false),
|
||||
Action::Undo => self.app.undo(),
|
||||
Action::Redo => self.app.redo(),
|
||||
Action::UndoBranchPrev => self.app.cycle_undo_branch(false),
|
||||
Action::UndoBranchNext => self.app.cycle_undo_branch(true),
|
||||
Action::Help => self.app.toggle_help(),
|
||||
Action::Command => {
|
||||
self.command_buffer.push(':');
|
||||
self.sync_command_prompt();
|
||||
}
|
||||
Action::Quit => self.request_quit(false),
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Looks up the current `key_sequence` in the configured keybindings.
|
||||
fn find_binding(&self) -> Option<Action> {
|
||||
self.bindings
|
||||
.iter()
|
||||
.find(|(bind, _)| *bind == self.key_sequence)
|
||||
.map(|(_, action)| *action)
|
||||
}
|
||||
|
||||
/// Returns true if the current `key_sequence` is a partial prefix of any configured bind.
|
||||
fn is_prefix_binding(&self) -> bool {
|
||||
let binds = [
|
||||
&self.config.keybinds.down,
|
||||
&self.config.keybinds.up,
|
||||
&self.config.keybinds.edit,
|
||||
&self.config.keybinds.edit_append,
|
||||
&self.config.keybinds.edit_substitute,
|
||||
&self.config.keybinds.search,
|
||||
&self.config.keybinds.next_match,
|
||||
&self.config.keybinds.previous_match,
|
||||
&self.config.keybinds.jump_top,
|
||||
&self.config.keybinds.jump_bottom,
|
||||
&self.config.keybinds.append_item,
|
||||
&self.config.keybinds.prepend_item,
|
||||
&self.config.keybinds.delete_item,
|
||||
&self.config.keybinds.undo,
|
||||
&self.config.keybinds.redo,
|
||||
&self.config.keybinds.rename,
|
||||
&self.config.keybinds.append_group,
|
||||
&self.config.keybinds.prepend_group,
|
||||
&self.config.keybinds.toggle_group,
|
||||
];
|
||||
|
||||
for bind in binds.iter() {
|
||||
if bind.starts_with(&self.key_sequence) && bind.as_str() != self.key_sequence {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
self.bindings
|
||||
.iter()
|
||||
.any(|(bind, _)| bind.starts_with(&self.key_sequence) && bind != &self.key_sequence)
|
||||
}
|
||||
|
||||
/// Adds a missing item from the template to the active configuration.
|
||||
fn add_missing_item(&mut self) {
|
||||
if let Some(var) = self.app.vars.get_mut(self.app.selected)
|
||||
&& var.status == crate::format::ItemStatus::MissingFromActive {
|
||||
var.status = crate::format::ItemStatus::Present;
|
||||
if !var.is_group {
|
||||
var.value = var.template_value.clone();
|
||||
}
|
||||
self.app.sync_input_with_selected();
|
||||
self.app.save_undo_state();
|
||||
}
|
||||
}
|
||||
|
||||
/// Delegates key events to the `tui_input` handler during active value editing.
|
||||
/// Delegates key events to the `tui_input` handler during active editing.
|
||||
fn handle_insert_mode(&mut self, key: KeyEvent) -> io::Result<()> {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.app.cancel_insert();
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
self.app.enter_normal();
|
||||
}
|
||||
KeyCode::Esc => self.app.cancel_insert(),
|
||||
KeyCode::Enter => self.app.enter_normal(),
|
||||
_ => {
|
||||
self.app.input.handle_event(&Event::Key(key));
|
||||
}
|
||||
@@ -303,37 +283,21 @@ where
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delegates key events to the `tui_input` handler during active key renaming.
|
||||
fn handle_insert_key_mode(&mut self, key: KeyEvent) -> io::Result<()> {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
self.app.cancel_insert();
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
self.app.enter_normal();
|
||||
}
|
||||
_ => {
|
||||
self.app.input.handle_event(&Event::Key(key));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handles search mode key events and live-updates search filtering.
|
||||
/// Handles search mode key events and live-updates the match position.
|
||||
fn handle_search_mode(&mut self, key: KeyEvent) -> io::Result<()> {
|
||||
match key.code {
|
||||
KeyCode::Enter | KeyCode::Esc => {
|
||||
self.app.mode = Mode::Normal;
|
||||
self.app.status_message = None;
|
||||
}
|
||||
// Enter keeps the query so `n`/`N` continue to work; Esc abandons it
|
||||
// and puts the cursor back where the search started.
|
||||
KeyCode::Enter => self.app.end_search(true),
|
||||
KeyCode::Esc => self.app.end_search(false),
|
||||
KeyCode::Backspace => {
|
||||
self.app.search_query.pop();
|
||||
self.app.status_message = Some(format!("{}{}", self.config.keybinds.search, self.app.search_query));
|
||||
self.sync_search_prompt();
|
||||
self.app.jump_next_match();
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
self.app.search_query.push(c);
|
||||
self.app.status_message = Some(format!("{}{}", self.config.keybinds.search, self.app.search_query));
|
||||
self.sync_search_prompt();
|
||||
self.app.jump_next_match();
|
||||
}
|
||||
_ => {}
|
||||
@@ -341,39 +305,85 @@ where
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Logic to map command strings (like ":w") to internal application actions.
|
||||
/// Maps command strings (like `:w`) to internal application actions.
|
||||
fn execute_command(&mut self, cmd: &str) -> io::Result<()> {
|
||||
if cmd == self.config.keybinds.save {
|
||||
self.save_file()
|
||||
} else if cmd == self.config.keybinds.quit {
|
||||
self.app.running = false;
|
||||
Ok(())
|
||||
} else if cmd == ":wq" {
|
||||
self.save_file()?;
|
||||
self.app.running = false;
|
||||
Ok(())
|
||||
} else {
|
||||
self.app.status_message = Some(format!("Unknown command: {}", cmd));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
let kb = &self.config.keybinds;
|
||||
let save = kb.save.clone();
|
||||
let quit = kb.quit.clone();
|
||||
|
||||
/// Attempts to write the current app state to the specified output file.
|
||||
fn save_file(&mut self) -> io::Result<()> {
|
||||
if self.handler.write(self.output_path, &self.app.vars).is_ok() {
|
||||
self.app.status_message = Some(format!("Saved to {}", self.output_path.display()));
|
||||
} else {
|
||||
self.app.status_message = Some("Error saving file".to_string());
|
||||
// `:w some/other/path` retargets the write without changing the session.
|
||||
if let Some(rest) = cmd.strip_prefix(&format!("{} ", save)) {
|
||||
let target = PathBuf::from(rest.trim());
|
||||
self.save_file(Some(target));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match cmd {
|
||||
c if c == save => {
|
||||
self.save_file(None);
|
||||
}
|
||||
c if c == quit => self.request_quit(false),
|
||||
":q!" => self.request_quit(true),
|
||||
":wq" | ":x" => {
|
||||
if self.save_file(None) {
|
||||
self.app.running = false;
|
||||
}
|
||||
}
|
||||
":help" | ":h" => self.app.toggle_help(),
|
||||
other => self.app.set_status(format!("Unknown command: {}", other)),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Synchronizes the status bar display with the active command buffer.
|
||||
fn sync_command_status(&mut self) {
|
||||
if self.command_buffer.is_empty() {
|
||||
self.app.status_message = None;
|
||||
} else {
|
||||
self.app.status_message = Some(self.command_buffer.clone());
|
||||
/// Quits, refusing to discard unsaved work unless `force` is set.
|
||||
fn request_quit(&mut self, force: bool) {
|
||||
if self.app.is_dirty && !force {
|
||||
self.app.set_status(format!(
|
||||
"Unsaved changes — {} to save, :q! to discard",
|
||||
self.config.keybinds.save
|
||||
));
|
||||
return;
|
||||
}
|
||||
self.app.running = false;
|
||||
}
|
||||
|
||||
/// Writes the current state to disk. Returns true on success.
|
||||
fn save_file(&mut self, target: Option<PathBuf>) -> bool {
|
||||
let path = target.unwrap_or_else(|| self.app.output_path.clone());
|
||||
match self.handler.write(&path, &self.app.vars) {
|
||||
Ok(()) => {
|
||||
self.app.output_path = path.clone();
|
||||
self.app.is_dirty = false;
|
||||
for var in self.app.vars.iter_mut() {
|
||||
var.dirty = false;
|
||||
}
|
||||
self.app.set_status(format!("Saved to {}", path.display()));
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
// The reason matters: a read-only path and an unrepresentable
|
||||
// document fail very differently.
|
||||
self.app.set_status(format!("Could not save {}: {}", path.display(), e));
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Synchronizes the prompt line with the active command buffer.
|
||||
fn sync_command_prompt(&mut self) {
|
||||
self.app.prompt = if self.command_buffer.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(self.command_buffer.clone())
|
||||
};
|
||||
}
|
||||
|
||||
/// Synchronizes the prompt line with the active search query.
|
||||
fn sync_search_prompt(&mut self) {
|
||||
self.app.prompt = Some(format!(
|
||||
"{}{}",
|
||||
self.config.keybinds.key_for(Action::Search),
|
||||
self.app.search_query
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user