Compare commits
6 Commits
51625ed296
...
release/0.
| Author | SHA1 | Date | |
|---|---|---|---|
| b07a2d8f75 | |||
| 029b4f9da8 | |||
| ee85be4e6b | |||
| 31dc062ce5 | |||
| 50278821ca | |||
| e3510a96fb |
145
README.md
145
README.md
@@ -4,37 +4,44 @@ mould is a modern Terminal User Interface (TUI) tool designed for interactively
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Universal Format Support**: Handle `.env`, `JSON`, `YAML`, `TOML`, `XML`, `INI`, and `Properties` seamlessly.
|
- **Universal Format Support**: Read, edit, and write `.env`, `JSON`, `YAML`, `TOML`, `XML`, `INI`, and `Properties` files seamlessly.
|
||||||
- **Tree View Navigation**: Edit nested data structures in a beautiful, depth-colored tree view.
|
- **Tree View Navigation**: Edit nested data structures in a beautiful, depth-colored tree view.
|
||||||
- **Smart Template Discovery**: Rule-based resolver automatically discovers relationships (e.g., `.env.example` vs `.env`, `config.template.properties` vs `config.properties`) and highlights differences.
|
- **Smart Template Discovery**: Rule-based resolver automatically discovers relationships (e.g., `.env.example` vs `.env`, `config.template.properties` vs `config.properties`) and highlights missing keys.
|
||||||
- **Strict Type Preservation**: Intelligently preserves data types (integers, booleans, strings) during edit-save cycles, ensuring your configuration stays valid.
|
- **Strict Type Preservation**: Intelligently preserves data types (integers, booleans, strings) during edit-save cycles, ensuring your configuration stays valid.
|
||||||
- **Add Missing Keys**: Instantly pull missing keys and their default values from your template into your active configuration with a single keystroke.
|
- **Add Missing Keys**: Instantly pull missing keys and their default values from your template into your active configuration with a single keystroke.
|
||||||
- **Neovim Integration**: Comes with a built-in Neovim plugin for seamless in-editor configuration management.
|
- **Advanced Undo/Redo Engine**: Features a tree-based undo/redo history that ensures you never lose changes during complex branching edits.
|
||||||
- **Docker Compose Integration**: Automatically generate `docker-compose.override.yml` from `docker-compose.yml`.
|
|
||||||
- **Vim-inspired Workflow**: Navigate with `j`/`k`, `gg`/`G`, edit with `i`, search with `/`, and save with `:w`.
|
- **Vim-inspired Workflow**: Navigate with `j`/`k`, `gg`/`G`, edit with `i`, search with `/`, and save with `:w`.
|
||||||
- **Modern UI**: A polished, rounded interface featuring a semantic Catppuccin Mocha palette.
|
- **Modern UI**: A polished, rounded interface featuring a semantic Catppuccin Mocha palette with support for terminal transparency.
|
||||||
- **Highly Configurable**: Customize keybindings and semantic themes via a simple TOML configuration.
|
- **Highly Configurable**: Customize keybindings and semantic themes via a simple TOML user configuration.
|
||||||
|
- **Neovim Integration**: Comes with a built-in Neovim plugin for seamless in-editor configuration management.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### CLI Application
|
### 1. CLI Application
|
||||||
|
|
||||||
Ensure you have Rust and Cargo installed, then run:
|
Ensure you have Rust and Cargo installed, then run:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
# Install directly from the local path
|
||||||
cargo install --path .
|
cargo install --path .
|
||||||
```
|
```
|
||||||
|
|
||||||
Alternatively, you can build from source:
|
Alternatively, you can build from source:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone <repository_url>
|
git clone https://git.narl.io/nvrl/mould-rs
|
||||||
cd mould
|
cd mould-rs
|
||||||
cargo build --release
|
cargo build --release
|
||||||
|
# The executable will be located in target/release/mould
|
||||||
```
|
```
|
||||||
|
|
||||||
### Neovim Plugin
|
### 2. Neovim Plugin
|
||||||
If you use a plugin manager like `mini.deps`, you can add the repository directly:
|
|
||||||
|
|
||||||
|
If you want to use `mould` directly inside Neovim, the repository includes a built-in Lua plugin that opens `mould` in a floating terminal buffer and synchronizes the file upon exit.
|
||||||
|
|
||||||
|
**Using `mini.deps`:**
|
||||||
```lua
|
```lua
|
||||||
add({
|
add({
|
||||||
source = 'https://git.narl.io/nvrl/mould-rs',
|
source = 'https://git.narl.io/nvrl/mould-rs',
|
||||||
@@ -42,9 +49,11 @@ add({
|
|||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
Provide an input template file to start editing. `mould` is smart enough to figure out if it's looking at a template or an active file, and will search for its counterpart to provide diffing.
|
Provide an input template file to start editing. `mould` is smart enough to figure out if it's looking at a template or an active file, and will search for its counterpart to provide live diffing.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
mould .env.example
|
mould .env.example
|
||||||
@@ -52,6 +61,12 @@ mould docker-compose.yml
|
|||||||
mould config.template.json -o config.json
|
mould config.template.json -o config.json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If you just run `mould` in a directory, it will automatically look for common template patterns like `.env.example` or `docker-compose.yml`.
|
||||||
|
|
||||||
|
### Inside Neovim
|
||||||
|
|
||||||
|
Open any configuration file in Neovim and run `:Mould`. It will launch a floating window targeting your active buffer.
|
||||||
|
|
||||||
### Keybindings (Default)
|
### Keybindings (Default)
|
||||||
|
|
||||||
- **Normal Mode**
|
- **Normal Mode**
|
||||||
@@ -60,51 +75,43 @@ mould config.template.json -o config.json
|
|||||||
- `gg`: Jump to the top
|
- `gg`: Jump to the top
|
||||||
- `G`: Jump to the bottom
|
- `G`: Jump to the bottom
|
||||||
- `i`: Edit value (cursor at start)
|
- `i`: Edit value (cursor at start)
|
||||||
- `A`: Edit value (cursor at end)
|
- `a`: Edit value (cursor at end)
|
||||||
- `S`: Substitute value (clear and edit)
|
- `s`: Substitute value (clear and edit)
|
||||||
- `o`: Append a new item to the current array
|
- `r`: Rename the current key
|
||||||
- `O`: Prepend a new item to the current array
|
- `o`: Append a new item (as a sibling or array element)
|
||||||
- `dd`: Delete the currently selected variable or group
|
- `O`: Prepend a new item
|
||||||
|
- `alt+o` / `alt+O`: Append/Prepend a new group/object
|
||||||
|
- `t`: Toggle between group/object and standard value
|
||||||
|
- `dd`: Delete the currently selected variable or group (removes all nested children)
|
||||||
- `u`: Undo the last change
|
- `u`: Undo the last change
|
||||||
|
- `U`: Redo the reverted change
|
||||||
- `a`: Add missing value from template to active config
|
- `a`: Add missing value from template to active config
|
||||||
- `/`: Search for configuration keys (Jump to matches)
|
- `/`: Search for configuration keys
|
||||||
- `n`: Jump to the next search match
|
- `n` / `N`: Jump to next / previous search match
|
||||||
- `N`: Jump to the previous search match
|
- `:w` or `Enter`: Save the current configuration
|
||||||
- `:w` or `Enter`: Save the current configuration to the output file
|
|
||||||
- `:q` or `q`: Quit the application
|
- `:q` or `q`: Quit the application
|
||||||
- `:wq`: Save and quit
|
- `:wq`: Save and quit
|
||||||
- `Esc`: Clear current command prompt
|
- `Esc`: Clear current command prompt
|
||||||
|
|
||||||
- **Insert Mode**
|
- **Insert / Rename Modes**
|
||||||
- Type your value for the selected key.
|
- Type your value/key string.
|
||||||
- Arrow keys: Navigate within the input field
|
- Arrow keys: Navigate within the input field
|
||||||
- `Enter` / `Esc`: Commit the value and return to Normal Mode
|
- `Enter`: Commit the value and return to Normal Mode
|
||||||
|
- `Esc`: Cancel edits and return to Normal Mode
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
mould can be configured using a `config.toml` file located in your user configuration directory (e.g., `~/.config/mould/config.toml` on Linux/macOS).
|
`mould` can be configured using a `config.toml` file located in your user configuration directory:
|
||||||
|
- **Linux/macOS**: `~/.config/mould/config.toml`
|
||||||
|
- **Windows**: `%AppData%\mould\config.toml`
|
||||||
|
|
||||||
Example configuration:
|
Example configuration:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[keybinds]
|
|
||||||
down = "j"
|
|
||||||
up = "k"
|
|
||||||
edit = "i"
|
|
||||||
save = ":w"
|
|
||||||
quit = ":q"
|
|
||||||
normal_mode = "Esc"
|
|
||||||
search = "/"
|
|
||||||
next_match = "n"
|
|
||||||
previous_match = "N"
|
|
||||||
jump_top = "gg"
|
|
||||||
jump_bottom = "G"
|
|
||||||
|
|
||||||
[theme]
|
[theme]
|
||||||
# Enable transparency to let your terminal background show through
|
|
||||||
transparent = false
|
transparent = false
|
||||||
|
|
||||||
# Custom color palette (Semantic Catppuccin Mocha defaults)
|
|
||||||
bg_normal = "#1e1e2e"
|
bg_normal = "#1e1e2e"
|
||||||
bg_highlight = "#89b4fa"
|
bg_highlight = "#89b4fa"
|
||||||
bg_active = "#a6e3a1"
|
bg_active = "#a6e3a1"
|
||||||
@@ -121,8 +128,60 @@ tree_depth_1 = "#b4befe"
|
|||||||
tree_depth_2 = "#cba6f7"
|
tree_depth_2 = "#cba6f7"
|
||||||
tree_depth_3 = "#89b4fa"
|
tree_depth_3 = "#89b4fa"
|
||||||
tree_depth_4 = "#fab387"
|
tree_depth_4 = "#fab387"
|
||||||
|
|
||||||
|
[keybinds]
|
||||||
|
down = "j"
|
||||||
|
up = "k"
|
||||||
|
edit = "i"
|
||||||
|
edit_append = "a"
|
||||||
|
edit_substitute = "s"
|
||||||
|
save = ":w"
|
||||||
|
quit = ":q"
|
||||||
|
normal_mode = "Esc"
|
||||||
|
search = "/"
|
||||||
|
next_match = "n"
|
||||||
|
previous_match = "N"
|
||||||
|
jump_top = "gg"
|
||||||
|
jump_bottom = "G"
|
||||||
|
append_item = "o"
|
||||||
|
prepend_item = "O"
|
||||||
|
delete_item = "dd"
|
||||||
|
undo = "u"
|
||||||
|
redo = "U"
|
||||||
|
rename = "r"
|
||||||
|
append_group = "alt+o"
|
||||||
|
prepend_group = "alt+O"
|
||||||
|
toggle_group = "t"
|
||||||
```
|
```
|
||||||
|
|
||||||
## License
|
---
|
||||||
|
|
||||||
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
|
## Development & Architecture
|
||||||
|
|
||||||
|
`mould` is written in Rust and architected to decouple the file format parsing from the UI representation. This allows the TUI to render complex, nested configuration files in a unified tree-view.
|
||||||
|
|
||||||
|
### Core Architecture
|
||||||
|
|
||||||
|
1. **State Management & Undo Tree (`src/app.rs` & `src/undo.rs`)**
|
||||||
|
- The central state is maintained in the `App` struct, which tracks the currently loaded configuration variables, application modes (`Normal`, `Insert`, `InsertKey`, `Search`), and user input buffer.
|
||||||
|
- It integrates an **UndoTree**, providing non-linear, branching history tracking for complex edits (additions, deletions, nested renaming).
|
||||||
|
|
||||||
|
2. **Unified Data Model (`src/format/mod.rs`)**
|
||||||
|
- Regardless of the underlying format, all data is translated into a flattened `Vec<ConfigItem>`.
|
||||||
|
- A `ConfigItem` contains its structural path (`Vec<PathSegment>` handling nested Object Keys and Array Indices), values, type metadata (`ValueType`), and template comparison statuses (e.g., `MissingFromActive`).
|
||||||
|
|
||||||
|
3. **Format Handlers (`src/format/*`)**
|
||||||
|
- **`env.rs` & `properties.rs`**: Handlers for flat key-value files.
|
||||||
|
- **`hierarchical.rs`**: A generalized processor leveraging `serde_json::Value` as an intermediary layer to parse and write nested `JSON`, `YAML`, `TOML`, and even `XML` (via `quick-xml` transposition).
|
||||||
|
- **`ini.rs`**: Handles traditional `[Section]` based INI configurations.
|
||||||
|
|
||||||
|
4. **Template Resolver (`src/resolver.rs`)**
|
||||||
|
- Automatically determines structural pairings without explicit instruction.
|
||||||
|
- Uses hardcoded exact rules (e.g., `compose.yml` -> `compose.override.yml`) and generic fallback rules to strip `.example` or `.template` suffixes to find target output files dynamically.
|
||||||
|
|
||||||
|
5. **Terminal UI & Event Loop (`src/ui.rs` & `src/runner.rs`)**
|
||||||
|
- **UI Rendering**: Powered by `ratatui`. Renders a conditional side-by-side or vertical layout consisting of a styled hierarchical List, an active Input field, and a status bar indicating keybind availability.
|
||||||
|
- **Event Runner**: Powered by `crossterm`. Intercepts keystrokes, resolves sequences (like `dd` or `gg`), delegates to the `tui-input` backend during active editing, and interacts with the internal API to mutate the configuration tree.
|
||||||
|
|
||||||
|
6. **Neovim Plugin (`lua/mould/init.lua`)**
|
||||||
|
- Implements a Lua wrapper that calculates terminal geometries and launches the CLI `mould` binary inside an ephemeral, floating terminal buffer, triggering automatic Neovim `checktime` syncs on exit.
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ tree_depth_4 = "#fab387"
|
|||||||
down = "j"
|
down = "j"
|
||||||
up = "k"
|
up = "k"
|
||||||
edit = "i"
|
edit = "i"
|
||||||
edit_append = "A"
|
edit_append = "a"
|
||||||
edit_substitute = "S"
|
edit_substitute = "s"
|
||||||
save = ":w"
|
save = ":w"
|
||||||
quit = ":q"
|
quit = ":q"
|
||||||
normal_mode = "Esc"
|
normal_mode = "Esc"
|
||||||
@@ -45,3 +45,8 @@ append_item = "o"
|
|||||||
prepend_item = "O"
|
prepend_item = "O"
|
||||||
delete_item = "dd"
|
delete_item = "dd"
|
||||||
undo = "u"
|
undo = "u"
|
||||||
|
redo = "U"
|
||||||
|
rename = "r"
|
||||||
|
append_group = "alt+o"
|
||||||
|
prepend_group = "alt+O"
|
||||||
|
toggle_group = "t"
|
||||||
|
|||||||
217
src/app.rs
217
src/app.rs
@@ -8,6 +8,8 @@ pub enum Mode {
|
|||||||
Normal,
|
Normal,
|
||||||
/// Active text entry mode for modifying values.
|
/// Active text entry mode for modifying values.
|
||||||
Insert,
|
Insert,
|
||||||
|
/// Active text entry mode for modifying keys.
|
||||||
|
InsertKey,
|
||||||
/// Active search mode for filtering keys.
|
/// Active search mode for filtering keys.
|
||||||
Search,
|
Search,
|
||||||
}
|
}
|
||||||
@@ -145,24 +147,108 @@ impl App {
|
|||||||
/// Updates the input buffer to reflect the value of the currently selected variable.
|
/// Updates the input buffer to reflect the value of the currently selected variable.
|
||||||
pub fn sync_input_with_selected(&mut self) {
|
pub fn sync_input_with_selected(&mut self) {
|
||||||
if let Some(var) = self.vars.get(self.selected) {
|
if let Some(var) = self.vars.get(self.selected) {
|
||||||
let val = var.value.clone().unwrap_or_default();
|
let val = match self.mode {
|
||||||
|
Mode::InsertKey => var.key.clone(),
|
||||||
|
_ => var.value.clone().unwrap_or_default(),
|
||||||
|
};
|
||||||
self.input = Input::new(val);
|
self.input = Input::new(val);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Commits the current text in the input buffer back to the selected variable's value.
|
/// Commits the current text in the input buffer back to the selected variable's value or key.
|
||||||
pub fn commit_input(&mut self) {
|
/// Returns true if commit was successful, false if there was an error (e.g. collision).
|
||||||
if let Some(var) = self.vars.get_mut(self.selected)
|
pub fn commit_input(&mut self) -> bool {
|
||||||
&& !var.is_group {
|
match self.mode {
|
||||||
var.value = Some(self.input.value().to_string());
|
Mode::Insert => {
|
||||||
var.status = crate::format::ItemStatus::Modified;
|
if let Some(var) = self.vars.get_mut(self.selected)
|
||||||
|
&& !var.is_group {
|
||||||
|
var.value = Some(self.input.value().to_string());
|
||||||
|
var.status = crate::format::ItemStatus::Modified;
|
||||||
|
}
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
Mode::InsertKey => {
|
||||||
|
let new_key = self.input.value().trim().to_string();
|
||||||
|
if new_key.is_empty() {
|
||||||
|
self.status_message = Some("Key cannot be empty".to_string());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let selected_var = self.vars[self.selected].clone();
|
||||||
|
if selected_var.key == new_key {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collision check: siblings share the same parent path
|
||||||
|
let parent_path = if selected_var.path.len() > 1 {
|
||||||
|
&selected_var.path[..selected_var.path.len() - 1]
|
||||||
|
} else {
|
||||||
|
&[]
|
||||||
|
};
|
||||||
|
|
||||||
|
let exists = self.vars.iter().enumerate().any(|(i, v)| {
|
||||||
|
i != self.selected
|
||||||
|
&& v.path.len() == selected_var.path.len()
|
||||||
|
&& v.path.starts_with(parent_path)
|
||||||
|
&& v.key == new_key
|
||||||
|
});
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
self.status_message = Some(format!("Key already exists: {}", new_key));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update selected item's key and path
|
||||||
|
let old_path = selected_var.path.clone();
|
||||||
|
let mut new_path = parent_path.to_vec();
|
||||||
|
new_path.push(PathSegment::Key(new_key.clone()));
|
||||||
|
|
||||||
|
{
|
||||||
|
let var = self.vars.get_mut(self.selected).unwrap();
|
||||||
|
var.key = new_key;
|
||||||
|
var.path = new_path.clone();
|
||||||
|
var.status = crate::format::ItemStatus::Modified;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update paths of all children if it's a group
|
||||||
|
if selected_var.is_group {
|
||||||
|
for var in self.vars.iter_mut() {
|
||||||
|
if var.path.starts_with(&old_path) && var.path.len() > old_path.len() {
|
||||||
|
let mut p = new_path.clone();
|
||||||
|
p.extend(var.path[old_path.len()..].iter().cloned());
|
||||||
|
var.path = p;
|
||||||
|
var.status = crate::format::ItemStatus::Modified;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
_ => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Transitions the application into Insert Mode for keys.
|
||||||
|
pub fn enter_insert_key(&mut self) {
|
||||||
|
if !self.vars.is_empty() {
|
||||||
|
if let Some(var) = self.vars.get(self.selected)
|
||||||
|
&& matches!(var.path.last(), Some(PathSegment::Index(_))) {
|
||||||
|
self.status_message = Some("Cannot rename array indices".to_string());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.mode = Mode::InsertKey;
|
||||||
|
self.sync_input_with_selected();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Transitions the application into Insert Mode with a specific variant.
|
/// Transitions the application into Insert Mode with a specific variant.
|
||||||
pub fn enter_insert(&mut self, variant: InsertVariant) {
|
pub fn enter_insert(&mut self, variant: InsertVariant) {
|
||||||
if let Some(var) = self.vars.get(self.selected)
|
if let Some(var) = self.vars.get(self.selected) {
|
||||||
&& !var.is_group {
|
if var.is_group {
|
||||||
|
self.enter_insert_key();
|
||||||
|
} else {
|
||||||
|
if !matches!(variant, InsertVariant::Substitute) {
|
||||||
|
self.sync_input_with_selected();
|
||||||
|
}
|
||||||
self.mode = Mode::Insert;
|
self.mode = Mode::Insert;
|
||||||
match variant {
|
match variant {
|
||||||
InsertVariant::Start => {
|
InsertVariant::Start => {
|
||||||
@@ -178,13 +264,22 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Commits the current input and transitions the application into Normal Mode.
|
/// Commits the current input and transitions the application into Normal Mode.
|
||||||
pub fn enter_normal(&mut self) {
|
pub fn enter_normal(&mut self) {
|
||||||
self.commit_input();
|
if self.commit_input() {
|
||||||
self.save_undo_state();
|
self.save_undo_state();
|
||||||
|
self.mode = Mode::Normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancels the current input and transitions the application into Normal Mode.
|
||||||
|
pub fn cancel_insert(&mut self) {
|
||||||
self.mode = Mode::Normal;
|
self.mode = Mode::Normal;
|
||||||
|
self.sync_input_with_selected();
|
||||||
|
self.status_message = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Deletes the currently selected item. If it's a group, deletes all children.
|
/// Deletes the currently selected item. If it's a group, deletes all children.
|
||||||
@@ -248,24 +343,28 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Adds a new item relative to the selected item.
|
/// Adds a new item relative to the selected item.
|
||||||
pub fn add_item(&mut self, after: bool) {
|
pub fn add_item(&mut self, after: bool, is_group: bool, as_child: bool) {
|
||||||
if self.vars.is_empty() {
|
if self.vars.is_empty() {
|
||||||
let new_key = "NEW_VAR".to_string();
|
let new_key = if is_group { "NEW_GROUP".to_string() } else { "NEW_VAR".to_string() };
|
||||||
self.vars.push(ConfigItem {
|
self.vars.push(ConfigItem {
|
||||||
key: new_key.clone(),
|
key: new_key.clone(),
|
||||||
path: vec![PathSegment::Key(new_key)],
|
path: vec![PathSegment::Key(new_key)],
|
||||||
value: Some("".to_string()),
|
value: if is_group { None } else { Some("".to_string()) },
|
||||||
template_value: None,
|
template_value: None,
|
||||||
default_value: None,
|
default_value: None,
|
||||||
depth: 0,
|
depth: 0,
|
||||||
is_group: false,
|
is_group,
|
||||||
status: crate::format::ItemStatus::Modified,
|
status: crate::format::ItemStatus::Modified,
|
||||||
value_type: crate::format::ValueType::String,
|
value_type: if is_group { crate::format::ValueType::Null } else { crate::format::ValueType::String },
|
||||||
});
|
});
|
||||||
self.selected = 0;
|
self.selected = 0;
|
||||||
self.sync_input_with_selected();
|
self.sync_input_with_selected();
|
||||||
self.save_undo_state();
|
self.save_undo_state();
|
||||||
self.enter_insert(InsertVariant::Start);
|
if is_group {
|
||||||
|
self.enter_insert_key();
|
||||||
|
} else {
|
||||||
|
self.enter_insert(InsertVariant::Start);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,8 +376,8 @@ impl App {
|
|||||||
let insert_pos;
|
let insert_pos;
|
||||||
let mut is_array_item = false;
|
let mut is_array_item = false;
|
||||||
|
|
||||||
if let Some(PathSegment::Index(idx)) = selected_item.path.last() {
|
if !as_child && let Some(PathSegment::Index(idx)) = selected_item.path.last() {
|
||||||
// ARRAY ITEM LOGIC
|
// ARRAY ITEM LOGIC (Adding sibling to an existing index)
|
||||||
is_array_item = true;
|
is_array_item = true;
|
||||||
let base_path = selected_item.path[..selected_item.path.len() - 1].to_vec();
|
let base_path = selected_item.path[..selected_item.path.len() - 1].to_vec();
|
||||||
let new_idx = if after { idx + 1 } else { *idx };
|
let new_idx = if after { idx + 1 } else { *idx };
|
||||||
@@ -299,11 +398,29 @@ impl App {
|
|||||||
new_path = base_path;
|
new_path = base_path;
|
||||||
new_path.push(PathSegment::Index(new_idx));
|
new_path.push(PathSegment::Index(new_idx));
|
||||||
new_depth = selected_item.depth;
|
new_depth = selected_item.depth;
|
||||||
} else if after && selected_item.is_group {
|
} else if as_child && selected_item.is_group {
|
||||||
// ADD AS CHILD OF GROUP
|
// ADD AS CHILD OF GROUP
|
||||||
insert_pos = self.selected + 1;
|
insert_pos = self.selected + 1;
|
||||||
new_path = selected_item.path.clone();
|
new_path = selected_item.path.clone();
|
||||||
new_depth = selected_item.depth + 1;
|
new_depth = selected_item.depth + 1;
|
||||||
|
|
||||||
|
// Check if this group already contains array items
|
||||||
|
if self.is_array_group(&selected_item.path) {
|
||||||
|
is_array_item = true;
|
||||||
|
let new_idx = 0; // Prepend to array
|
||||||
|
new_path.push(PathSegment::Index(new_idx));
|
||||||
|
|
||||||
|
// Shift existing children
|
||||||
|
for var in self.vars.iter_mut() {
|
||||||
|
if var.path.starts_with(&selected_item.path) && var.path.len() > selected_item.path.len()
|
||||||
|
&& let PathSegment::Index(i) = var.path[selected_item.path.len()] {
|
||||||
|
var.path[selected_item.path.len()] = PathSegment::Index(i + 1);
|
||||||
|
if var.path.len() == selected_item.path.len() + 1 {
|
||||||
|
var.key = format!("[{}]", i + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// ADD AS SIBLING
|
// ADD AS SIBLING
|
||||||
let parent_path = if selected_item.path.len() > 1 {
|
let parent_path = if selected_item.path.len() > 1 {
|
||||||
@@ -324,6 +441,17 @@ impl App {
|
|||||||
|
|
||||||
new_path = parent_path;
|
new_path = parent_path;
|
||||||
new_depth = selected_item.depth;
|
new_depth = selected_item.depth;
|
||||||
|
|
||||||
|
// If the parent is an array group, this is also an array item
|
||||||
|
if !new_path.is_empty() && self.is_array_group(&new_path) {
|
||||||
|
is_array_item = true;
|
||||||
|
if let Some(PathSegment::Index(idx)) = selected_item.path.last() {
|
||||||
|
let new_idx = if after { idx + 1 } else { *idx };
|
||||||
|
new_path.push(PathSegment::Index(new_idx));
|
||||||
|
} else {
|
||||||
|
new_path.push(PathSegment::Index(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Generate a unique key for non-array items
|
// 2. Generate a unique key for non-array items
|
||||||
@@ -335,7 +463,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let mut count = 1;
|
let mut count = 1;
|
||||||
let mut candidate = "NEW_VAR".to_string();
|
let mut candidate = if is_group { "NEW_GROUP".to_string() } else { "NEW_VAR".to_string() };
|
||||||
let parent_path_slice = new_path.as_slice();
|
let parent_path_slice = new_path.as_slice();
|
||||||
|
|
||||||
while self.vars.iter().any(|v| {
|
while self.vars.iter().any(|v| {
|
||||||
@@ -343,7 +471,7 @@ impl App {
|
|||||||
&& v.path.len() == parent_path_slice.len() + 1
|
&& v.path.len() == parent_path_slice.len() + 1
|
||||||
&& v.key == candidate
|
&& v.key == candidate
|
||||||
}) {
|
}) {
|
||||||
candidate = format!("NEW_VAR_{}", count);
|
candidate = if is_group { format!("NEW_GROUP_{}", count) } else { format!("NEW_VAR_{}", count) };
|
||||||
count += 1;
|
count += 1;
|
||||||
}
|
}
|
||||||
new_path.push(PathSegment::Key(candidate.clone()));
|
new_path.push(PathSegment::Key(candidate.clone()));
|
||||||
@@ -354,28 +482,63 @@ impl App {
|
|||||||
let new_item = ConfigItem {
|
let new_item = ConfigItem {
|
||||||
key: final_key,
|
key: final_key,
|
||||||
path: new_path,
|
path: new_path,
|
||||||
value: Some("".to_string()),
|
value: if is_group { None } else { Some("".to_string()) },
|
||||||
template_value: None,
|
template_value: None,
|
||||||
default_value: None,
|
default_value: None,
|
||||||
depth: new_depth,
|
depth: new_depth,
|
||||||
is_group: false,
|
is_group,
|
||||||
status: crate::format::ItemStatus::Modified,
|
status: crate::format::ItemStatus::Modified,
|
||||||
value_type: crate::format::ValueType::String,
|
value_type: if is_group { crate::format::ValueType::Null } else { crate::format::ValueType::String },
|
||||||
};
|
};
|
||||||
|
|
||||||
self.vars.insert(insert_pos, new_item);
|
self.vars.insert(insert_pos, new_item);
|
||||||
self.selected = insert_pos;
|
self.selected = insert_pos;
|
||||||
self.sync_input_with_selected();
|
|
||||||
self.save_undo_state();
|
self.save_undo_state();
|
||||||
self.enter_insert(InsertVariant::Start);
|
|
||||||
|
if is_array_item {
|
||||||
|
self.sync_input_with_selected();
|
||||||
|
self.enter_insert(InsertVariant::Start);
|
||||||
|
} else {
|
||||||
|
self.enter_insert_key();
|
||||||
|
}
|
||||||
self.status_message = None;
|
self.status_message = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Toggles the group status of the currently selected item.
|
||||||
|
pub fn toggle_group_selected(&mut self) {
|
||||||
|
if let Some(var) = self.vars.get_mut(self.selected) {
|
||||||
|
// Cannot toggle array items (always vars)
|
||||||
|
if matches!(var.path.last(), Some(PathSegment::Index(_))) {
|
||||||
|
self.status_message = Some("Cannot toggle array items".to_string());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var.is_group = !var.is_group;
|
||||||
|
if var.is_group {
|
||||||
|
var.value = None;
|
||||||
|
var.value_type = crate::format::ValueType::Null;
|
||||||
|
} else {
|
||||||
|
var.value = Some("".to_string());
|
||||||
|
var.value_type = crate::format::ValueType::String;
|
||||||
|
}
|
||||||
|
var.status = crate::format::ItemStatus::Modified;
|
||||||
|
self.sync_input_with_selected();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Status bar helpers
|
/// Status bar helpers
|
||||||
pub fn selected_is_group(&self) -> bool {
|
pub fn selected_is_group(&self) -> bool {
|
||||||
self.vars.get(self.selected).map(|v| v.is_group).unwrap_or(false)
|
self.vars.get(self.selected).map(|v| v.is_group).unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn is_array_group(&self, group_path: &[PathSegment]) -> bool {
|
||||||
|
self.vars.iter().any(|v|
|
||||||
|
v.path.starts_with(group_path)
|
||||||
|
&& v.path.len() == group_path.len() + 1
|
||||||
|
&& matches!(v.path.last(), Some(PathSegment::Index(_)))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn selected_is_array(&self) -> bool {
|
pub fn selected_is_array(&self) -> bool {
|
||||||
self.vars.get(self.selected)
|
self.vars.get(self.selected)
|
||||||
.map(|v| !v.is_group && matches!(v.path.last(), Some(PathSegment::Index(_))))
|
.map(|v| !v.is_group && matches!(v.path.last(), Some(PathSegment::Index(_))))
|
||||||
|
|||||||
@@ -121,6 +121,10 @@ pub struct KeybindsConfig {
|
|||||||
pub delete_item: String,
|
pub delete_item: String,
|
||||||
pub undo: String,
|
pub undo: String,
|
||||||
pub redo: String,
|
pub redo: String,
|
||||||
|
pub rename: String,
|
||||||
|
pub append_group: String,
|
||||||
|
pub prepend_group: String,
|
||||||
|
pub toggle_group: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for KeybindsConfig {
|
impl Default for KeybindsConfig {
|
||||||
@@ -144,6 +148,10 @@ pub struct KeybindsConfig {
|
|||||||
delete_item: "dd".to_string(),
|
delete_item: "dd".to_string(),
|
||||||
undo: "u".to_string(),
|
undo: "u".to_string(),
|
||||||
redo: "U".to_string(),
|
redo: "U".to_string(),
|
||||||
|
rename: "r".to_string(),
|
||||||
|
append_group: "alt+o".to_string(),
|
||||||
|
prepend_group: "alt+O".to_string(),
|
||||||
|
toggle_group: "t".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -576,25 +576,43 @@ enabled = true
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_xml_flatten_unflatten() {
|
fn test_group_rename_write() {
|
||||||
let xml_str = "<config><server><port>8080</port><enabled>true</enabled></server></config>";
|
|
||||||
|
|
||||||
let json_val = xml_to_json(xml_str).unwrap();
|
|
||||||
|
|
||||||
let mut vars = Vec::new();
|
let mut vars = Vec::new();
|
||||||
flatten(&json_val, Vec::new(), Some("".to_string()), 0, &mut vars);
|
let json = serde_json::json!({
|
||||||
|
"old_group": {
|
||||||
|
"key": "val"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
flatten(&json, Vec::new(), Some("".to_string()), 0, &mut vars);
|
||||||
|
assert_eq!(vars.len(), 2);
|
||||||
|
assert_eq!(vars[0].key, "old_group");
|
||||||
|
assert_eq!(vars[0].is_group, true);
|
||||||
|
assert_eq!(vars[1].key, "key");
|
||||||
|
assert_eq!(vars[1].path_string(), "old_group.key");
|
||||||
|
|
||||||
|
// Manually simulate a rename of "old_group" to "new_group"
|
||||||
|
let old_path = vars[0].path.clone();
|
||||||
|
let new_key = "new_group".to_string();
|
||||||
|
let mut new_path = vec![PathSegment::Key(new_key.clone())];
|
||||||
|
|
||||||
|
vars[0].key = new_key;
|
||||||
|
vars[0].path = new_path.clone();
|
||||||
|
|
||||||
|
// Update child path
|
||||||
|
vars[1].path = vec![PathSegment::Key("new_group".to_string()), PathSegment::Key("key".to_string())];
|
||||||
|
|
||||||
|
let handler = HierarchicalHandler::new(FormatType::Json);
|
||||||
let mut root = Value::Object(Map::new());
|
let mut root = Value::Object(Map::new());
|
||||||
for var in vars {
|
for var in &vars {
|
||||||
if !var.is_group {
|
if !var.is_group {
|
||||||
insert_into_value(&mut root, &var.path, var.value.as_deref().unwrap_or(""), var.value_type);
|
insert_into_value(&mut root, &var.path, var.value.as_deref().unwrap_or(""), var.value_type);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let unflattened_xml = json_to_xml(&root);
|
let out = serde_json::to_string(&root).unwrap();
|
||||||
|
assert!(out.contains("\"new_group\""));
|
||||||
assert!(unflattened_xml.contains("<port>8080</port>"));
|
assert!(out.contains("\"key\":\"val\""));
|
||||||
assert!(unflattened_xml.contains("<enabled>true</enabled>"));
|
assert!(!out.contains("\"old_group\""));
|
||||||
assert!(unflattened_xml.contains("<config>") && unflattened_xml.contains("</config>"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,15 +80,46 @@ mod tests {
|
|||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_parse_ini() {
|
fn test_section_rename_write() {
|
||||||
let mut file = NamedTempFile::new().unwrap();
|
|
||||||
writeln!(file, "[server]\nport=8080\n[database]\nhost=localhost").unwrap();
|
|
||||||
|
|
||||||
let handler = IniHandler;
|
let handler = IniHandler;
|
||||||
let vars = handler.parse(file.path()).unwrap();
|
let mut vars = vec![
|
||||||
|
ConfigItem {
|
||||||
|
key: "server".to_string(),
|
||||||
|
path: vec![PathSegment::Key("server".to_string())],
|
||||||
|
value: None,
|
||||||
|
template_value: None,
|
||||||
|
default_value: None,
|
||||||
|
depth: 0,
|
||||||
|
is_group: true,
|
||||||
|
status: ItemStatus::Present,
|
||||||
|
value_type: ValueType::Null,
|
||||||
|
},
|
||||||
|
ConfigItem {
|
||||||
|
key: "port".to_string(),
|
||||||
|
path: vec![PathSegment::Key("server".to_string()), PathSegment::Key("port".to_string())],
|
||||||
|
value: Some("8080".to_string()),
|
||||||
|
template_value: Some("8080".to_string()),
|
||||||
|
default_value: Some("8080".to_string()),
|
||||||
|
depth: 1,
|
||||||
|
is_group: false,
|
||||||
|
status: ItemStatus::Present,
|
||||||
|
value_type: ValueType::String,
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Rename "server" to "srv"
|
||||||
|
vars[0].key = "srv".to_string();
|
||||||
|
vars[0].path = vec![PathSegment::Key("srv".to_string())];
|
||||||
|
|
||||||
assert!(vars.iter().any(|v| v.path_string() == "server" && v.is_group));
|
// Update child path
|
||||||
assert!(vars.iter().any(|v| v.path_string() == "server.port" && v.value.as_deref() == Some("8080")));
|
vars[1].path = vec![PathSegment::Key("srv".to_string()), PathSegment::Key("port".to_string())];
|
||||||
assert!(vars.iter().any(|v| v.path_string() == "database.host" && v.value.as_deref() == Some("localhost")));
|
|
||||||
|
let file = NamedTempFile::new().unwrap();
|
||||||
|
handler.write(file.path(), &vars).unwrap();
|
||||||
|
|
||||||
|
let content = std::fs::read_to_string(file.path()).unwrap();
|
||||||
|
assert!(content.contains("[srv]"));
|
||||||
|
assert!(content.contains("port=8080"));
|
||||||
|
assert!(!content.contains("[server]"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,14 +89,45 @@ mod tests {
|
|||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_parse_properties() {
|
fn test_group_rename_write() {
|
||||||
let mut file = NamedTempFile::new().unwrap();
|
|
||||||
writeln!(file, "server.port=8080\ndatabase.host=localhost").unwrap();
|
|
||||||
|
|
||||||
let handler = PropertiesHandler;
|
let handler = PropertiesHandler;
|
||||||
let vars = handler.parse(file.path()).unwrap();
|
let mut vars = vec![
|
||||||
|
ConfigItem {
|
||||||
|
key: "server".to_string(),
|
||||||
|
path: vec![PathSegment::Key("server".to_string())],
|
||||||
|
value: None,
|
||||||
|
template_value: None,
|
||||||
|
default_value: None,
|
||||||
|
depth: 0,
|
||||||
|
is_group: true,
|
||||||
|
status: ItemStatus::Present,
|
||||||
|
value_type: ValueType::Null,
|
||||||
|
},
|
||||||
|
ConfigItem {
|
||||||
|
key: "port".to_string(),
|
||||||
|
path: vec![PathSegment::Key("server".to_string()), PathSegment::Key("port".to_string())],
|
||||||
|
value: Some("8080".to_string()),
|
||||||
|
template_value: Some("8080".to_string()),
|
||||||
|
default_value: Some("8080".to_string()),
|
||||||
|
depth: 1,
|
||||||
|
is_group: false,
|
||||||
|
status: ItemStatus::Present,
|
||||||
|
value_type: ValueType::String,
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Rename "server" to "srv"
|
||||||
|
vars[0].key = "srv".to_string();
|
||||||
|
vars[0].path = vec![PathSegment::Key("srv".to_string())];
|
||||||
|
|
||||||
assert!(vars.iter().any(|v| v.path_string() == "server" && v.is_group));
|
// Update child path
|
||||||
assert!(vars.iter().any(|v| v.path_string() == "server.port" && v.value.as_deref() == Some("8080")));
|
vars[1].path = vec![PathSegment::Key("srv".to_string()), PathSegment::Key("port".to_string())];
|
||||||
|
|
||||||
|
let file = NamedTempFile::new().unwrap();
|
||||||
|
handler.write(file.path(), &vars).unwrap();
|
||||||
|
|
||||||
|
let content = std::fs::read_to_string(file.path()).unwrap();
|
||||||
|
assert!(content.contains("srv.port=8080"));
|
||||||
|
assert!(!content.contains("server.port=8080"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ where
|
|||||||
match self.app.mode {
|
match self.app.mode {
|
||||||
Mode::Normal => self.handle_normal_mode(key),
|
Mode::Normal => self.handle_normal_mode(key),
|
||||||
Mode::Insert => self.handle_insert_mode(key),
|
Mode::Insert => self.handle_insert_mode(key),
|
||||||
|
Mode::InsertKey => self.handle_insert_key_mode(key),
|
||||||
Mode::Search => self.handle_search_mode(key),
|
Mode::Search => self.handle_search_mode(key),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -109,8 +110,22 @@ where
|
|||||||
|
|
||||||
/// Handles primary navigation (j/k) and transitions to insert or command modes.
|
/// Handles primary navigation (j/k) and transitions to insert or command modes.
|
||||||
fn handle_navigation_mode(&mut self, key: KeyEvent) -> io::Result<()> {
|
fn handle_navigation_mode(&mut self, key: KeyEvent) -> io::Result<()> {
|
||||||
if let KeyCode::Char(c) = key.code {
|
let key_str = if let KeyCode::Char(c) = key.code {
|
||||||
self.key_sequence.push(c);
|
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 exact_match = None;
|
||||||
|
let mut prefix_match = false;
|
||||||
|
|
||||||
// Collect all configured keybinds
|
// Collect all configured keybinds
|
||||||
let binds = [
|
let binds = [
|
||||||
@@ -119,6 +134,7 @@ where
|
|||||||
(&self.config.keybinds.edit, "edit"),
|
(&self.config.keybinds.edit, "edit"),
|
||||||
(&self.config.keybinds.edit_append, "edit_append"),
|
(&self.config.keybinds.edit_append, "edit_append"),
|
||||||
(&self.config.keybinds.edit_substitute, "edit_substitute"),
|
(&self.config.keybinds.edit_substitute, "edit_substitute"),
|
||||||
|
(&"S".to_string(), "edit_substitute"),
|
||||||
(&self.config.keybinds.search, "search"),
|
(&self.config.keybinds.search, "search"),
|
||||||
(&self.config.keybinds.next_match, "next_match"),
|
(&self.config.keybinds.next_match, "next_match"),
|
||||||
(&self.config.keybinds.previous_match, "previous_match"),
|
(&self.config.keybinds.previous_match, "previous_match"),
|
||||||
@@ -129,14 +145,15 @@ where
|
|||||||
(&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"),
|
(&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"),
|
(&"a".to_string(), "add_missing"),
|
||||||
(&":".to_string(), "command"),
|
(&":".to_string(), "command"),
|
||||||
(&"q".to_string(), "quit"),
|
(&"q".to_string(), "quit"),
|
||||||
];
|
];
|
||||||
|
|
||||||
let mut exact_match = None;
|
|
||||||
let mut prefix_match = false;
|
|
||||||
|
|
||||||
for (bind, action) in binds.iter() {
|
for (bind, action) in binds.iter() {
|
||||||
if bind == &&self.key_sequence {
|
if bind == &&self.key_sequence {
|
||||||
exact_match = Some(*action);
|
exact_match = Some(*action);
|
||||||
@@ -146,6 +163,21 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if exact_match.is_none() && !prefix_match {
|
||||||
|
// Not a match and not a prefix, restart with current key
|
||||||
|
self.key_sequence.clear();
|
||||||
|
self.key_sequence.push_str(&key_str);
|
||||||
|
|
||||||
|
for (bind, action) in binds.iter() {
|
||||||
|
if bind == &&self.key_sequence {
|
||||||
|
exact_match = Some(*action);
|
||||||
|
break;
|
||||||
|
} else if bind.starts_with(&self.key_sequence) {
|
||||||
|
prefix_match = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(action) = exact_match {
|
if let Some(action) = exact_match {
|
||||||
self.key_sequence.clear();
|
self.key_sequence.clear();
|
||||||
match action {
|
match action {
|
||||||
@@ -163,11 +195,18 @@ where
|
|||||||
"previous_match" => self.app.jump_previous_match(),
|
"previous_match" => self.app.jump_previous_match(),
|
||||||
"jump_top" => self.app.jump_top(),
|
"jump_top" => self.app.jump_top(),
|
||||||
"jump_bottom" => self.app.jump_bottom(),
|
"jump_bottom" => self.app.jump_bottom(),
|
||||||
"append_item" => self.app.add_item(true),
|
"append_item" => self.app.add_item(true, false, false),
|
||||||
"prepend_item" => self.app.add_item(false),
|
"prepend_item" => self.app.add_item(false, false, 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(),
|
"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" => {
|
"add_missing" => {
|
||||||
self.add_missing_item();
|
self.add_missing_item();
|
||||||
}
|
}
|
||||||
@@ -179,9 +218,7 @@ where
|
|||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
} else if !prefix_match {
|
} else if !prefix_match {
|
||||||
// Not an exact match and not a prefix for any bind, clear and restart seq
|
|
||||||
self.key_sequence.clear();
|
self.key_sequence.clear();
|
||||||
self.key_sequence.push(c);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Non-character keys reset the sequence buffer
|
// Non-character keys reset the sequence buffer
|
||||||
@@ -213,7 +250,26 @@ where
|
|||||||
/// Delegates key events to the `tui_input` handler during active editing.
|
/// Delegates key events to the `tui_input` handler during active editing.
|
||||||
fn handle_insert_mode(&mut self, key: KeyEvent) -> io::Result<()> {
|
fn handle_insert_mode(&mut self, key: KeyEvent) -> io::Result<()> {
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Esc | KeyCode::Enter => {
|
KeyCode::Esc => {
|
||||||
|
self.app.cancel_insert();
|
||||||
|
}
|
||||||
|
KeyCode::Enter => {
|
||||||
|
self.app.enter_normal();
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
self.app.input.handle_event(&Event::Key(key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles keys in InsertKey mode.
|
||||||
|
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.enter_normal();
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
|
|||||||
67
src/ui.rs
67
src/ui.rs
@@ -103,24 +103,30 @@ pub fn draw(f: &mut Frame, app: &mut App, config: &Config) {
|
|||||||
Span::styled(&var.key, key_style),
|
Span::styled(&var.key, key_style),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Add status indicator if not present (only for leaf variables)
|
// Add status indicator if not present
|
||||||
if !var.is_group {
|
match var.status {
|
||||||
match var.status {
|
crate::format::ItemStatus::MissingFromActive if !var.is_group => {
|
||||||
crate::format::ItemStatus::MissingFromActive => {
|
let missing_style = if is_selected {
|
||||||
let missing_style = if is_selected {
|
Style::default().fg(theme.fg_highlight()).add_modifier(Modifier::BOLD)
|
||||||
Style::default().fg(theme.fg_highlight()).add_modifier(Modifier::BOLD)
|
} else {
|
||||||
} else {
|
Style::default().fg(theme.fg_warning()).add_modifier(Modifier::BOLD)
|
||||||
Style::default().fg(theme.fg_warning()).add_modifier(Modifier::BOLD)
|
};
|
||||||
};
|
key_spans.push(Span::styled(" (missing)", missing_style));
|
||||||
key_spans.push(Span::styled(" (missing)", missing_style));
|
|
||||||
}
|
|
||||||
crate::format::ItemStatus::Modified => {
|
|
||||||
if !is_selected {
|
|
||||||
key_spans.push(Span::styled(" (*)", Style::default().fg(theme.fg_modified())));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
|
crate::format::ItemStatus::MissingFromActive if var.is_group => {
|
||||||
|
let missing_style = if is_selected {
|
||||||
|
Style::default().fg(theme.fg_highlight()).add_modifier(Modifier::BOLD)
|
||||||
|
} else {
|
||||||
|
Style::default().fg(theme.fg_warning()).add_modifier(Modifier::BOLD)
|
||||||
|
};
|
||||||
|
key_spans.push(Span::styled(" (missing group)", missing_style));
|
||||||
|
}
|
||||||
|
crate::format::ItemStatus::Modified => {
|
||||||
|
if !is_selected {
|
||||||
|
key_spans.push(Span::styled(" (*)", Style::default().fg(theme.fg_modified())));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
let item_style = if is_selected {
|
let item_style = if is_selected {
|
||||||
@@ -188,7 +194,9 @@ pub fn draw(f: &mut Frame, app: &mut App, config: &Config) {
|
|||||||
let mut extra_info = String::new();
|
let mut extra_info = String::new();
|
||||||
|
|
||||||
if let Some(var) = current_var {
|
if let Some(var) = current_var {
|
||||||
if var.is_group {
|
if matches!(app.mode, Mode::InsertKey) {
|
||||||
|
input_title = format!(" Rename Key: {} ", var.path_string());
|
||||||
|
} else if var.is_group {
|
||||||
input_title = format!(" Group: {} ", var.path_string());
|
input_title = format!(" Group: {} ", var.path_string());
|
||||||
} else {
|
} else {
|
||||||
input_title = format!(" Editing: {} ", var.path_string());
|
input_title = format!(" Editing: {} ", var.path_string());
|
||||||
@@ -199,7 +207,7 @@ pub fn draw(f: &mut Frame, app: &mut App, config: &Config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let input_border_color = match app.mode {
|
let input_border_color = match app.mode {
|
||||||
Mode::Insert => theme.border_active(),
|
Mode::Insert | Mode::InsertKey => theme.border_active(),
|
||||||
Mode::Normal | Mode::Search => theme.border_normal(),
|
Mode::Normal | Mode::Search => theme.border_normal(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -208,7 +216,9 @@ pub fn draw(f: &mut Frame, app: &mut App, config: &Config) {
|
|||||||
|
|
||||||
// Show template value in normal mode if it differs
|
// Show template value in normal mode if it differs
|
||||||
let display_text = if let Some(var) = current_var {
|
let display_text = if let Some(var) = current_var {
|
||||||
if var.is_group {
|
if matches!(app.mode, Mode::InsertKey) {
|
||||||
|
input_text.to_string()
|
||||||
|
} else if var.is_group {
|
||||||
"<group>".to_string()
|
"<group>".to_string()
|
||||||
} else if matches!(app.mode, Mode::Normal) {
|
} else if matches!(app.mode, Mode::Normal) {
|
||||||
format!("{}{}", input_text, extra_info)
|
format!("{}{}", input_text, extra_info)
|
||||||
@@ -236,7 +246,7 @@ pub fn draw(f: &mut Frame, app: &mut App, config: &Config) {
|
|||||||
f.render_widget(input, chunks[2]);
|
f.render_widget(input, chunks[2]);
|
||||||
|
|
||||||
// Position the terminal cursor correctly when in Insert mode.
|
// Position the terminal cursor correctly when in Insert mode.
|
||||||
if let Mode::Insert = app.mode {
|
if matches!(app.mode, Mode::Insert) || matches!(app.mode, Mode::InsertKey) {
|
||||||
f.set_cursor_position(ratatui::layout::Position::new(
|
f.set_cursor_position(ratatui::layout::Position::new(
|
||||||
chunks[2].x + 1 + cursor_pos as u16,
|
chunks[2].x + 1 + cursor_pos as u16,
|
||||||
chunks[2].y + 1,
|
chunks[2].y + 1,
|
||||||
@@ -259,6 +269,13 @@ pub fn draw(f: &mut Frame, app: &mut App, config: &Config) {
|
|||||||
.fg(theme.bg_normal())
|
.fg(theme.bg_normal())
|
||||||
.add_modifier(Modifier::BOLD),
|
.add_modifier(Modifier::BOLD),
|
||||||
),
|
),
|
||||||
|
Mode::InsertKey => (
|
||||||
|
" RENAME ",
|
||||||
|
Style::default()
|
||||||
|
.bg(theme.bg_active())
|
||||||
|
.fg(theme.bg_normal())
|
||||||
|
.add_modifier(Modifier::BOLD),
|
||||||
|
),
|
||||||
Mode::Search => (
|
Mode::Search => (
|
||||||
" SEARCH ",
|
" SEARCH ",
|
||||||
Style::default()
|
Style::default()
|
||||||
@@ -282,11 +299,16 @@ pub fn draw(f: &mut Frame, app: &mut App, config: &Config) {
|
|||||||
if !app.selected_is_group() {
|
if !app.selected_is_group() {
|
||||||
parts.push(format!("{}/{}/{} edit", kb.edit, kb.edit_append, kb.edit_substitute));
|
parts.push(format!("{}/{}/{} edit", kb.edit, kb.edit_append, kb.edit_substitute));
|
||||||
}
|
}
|
||||||
|
parts.push(format!("{} rename", kb.rename));
|
||||||
|
parts.push(format!("{} toggle", kb.toggle_group));
|
||||||
if app.selected_is_missing() {
|
if app.selected_is_missing() {
|
||||||
parts.push(format!("{} add", "a")); // 'a' is currently hardcoded in runner
|
parts.push(format!("{} add", "a")); // 'a' is currently hardcoded in runner
|
||||||
}
|
}
|
||||||
if app.selected_is_array() {
|
if app.selected_is_array() {
|
||||||
parts.push(format!("{}/{} array", kb.append_item, kb.prepend_item));
|
parts.push(format!("{}/{} array", kb.append_item, kb.prepend_item));
|
||||||
|
} else {
|
||||||
|
parts.push(format!("{}/{} add", kb.append_item, kb.prepend_item));
|
||||||
|
parts.push(format!("{}/{} group", kb.append_group, kb.prepend_group));
|
||||||
}
|
}
|
||||||
parts.push(format!("{} del", kb.delete_item));
|
parts.push(format!("{} del", kb.delete_item));
|
||||||
parts.push(format!("{} undo", kb.undo));
|
parts.push(format!("{} undo", kb.undo));
|
||||||
@@ -294,7 +316,8 @@ pub fn draw(f: &mut Frame, app: &mut App, config: &Config) {
|
|||||||
parts.push(format!("{} quit", kb.quit));
|
parts.push(format!("{} quit", kb.quit));
|
||||||
parts.join(" · ")
|
parts.join(" · ")
|
||||||
}
|
}
|
||||||
Mode::Insert => "Esc normal · Enter commit".to_string(),
|
Mode::Insert => "Esc cancel · Enter commit".to_string(),
|
||||||
|
Mode::InsertKey => "Esc cancel · Enter rename".to_string(),
|
||||||
Mode::Search => "Esc normal · type to filter".to_string(),
|
Mode::Search => "Esc normal · type to filter".to_string(),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user