From 7aa45974a7d590793b5a900f667ac4690cca9d64 Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Tue, 17 Mar 2026 12:23:10 +0100 Subject: [PATCH] fixed ui and themes for tree view + neovim plugin wrapper --- Cargo.toml | 30 ++++-- Cargo.toml.out | 33 ------ README.md | 66 ++++++++---- config.example.toml | 45 ++++---- lua/mould/init.lua | 54 ++++++++++ plugin/mould.lua | 8 ++ src/app.rs | 40 ++++++-- src/config.rs | 124 ++++++++++++---------- src/format/env.rs | 82 ++++++++++----- src/format/hierarchical.rs | 145 +++++++++++++++++++------- src/format/mod.rs | 25 +++-- src/main.rs | 69 +++++++++++-- src/runner.rs | 95 +++++++++++++---- src/ui.rs | 203 ++++++++++++++++++++++++------------- 14 files changed, 696 insertions(+), 323 deletions(-) delete mode 100644 Cargo.toml.out create mode 100644 lua/mould/init.lua create mode 100644 plugin/mould.lua diff --git a/Cargo.toml b/Cargo.toml index ea1a818..77e60d9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,27 +1,39 @@ -[package] -name = "mould" -version = "0.2.1" -edition = "2024" -authors = ["Nils Pukropp "] - -[[bin]] +[[bin.""]] name = "mould" path = "src/main.rs" [dependencies] anyhow = "1.0.102" -clap = { version = "4.6.0", features = ["derive"] } crossterm = "0.29.0" dirs = "6.0.0" env_logger = "0.11.9" log = "0.4.29" ratatui = "0.30.0" -serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" serde_yaml = "0.9.34" thiserror = "2.0.18" toml = "1.0.6" tui-input = "0.15.0" +[dependencies.clap] +version = "4.6.0" + +[dependencies.clap.features] +"" = ["derive"] + +[dependencies.serde] +version = "1.0.228" + +[dependencies.serde.features] +"" = ["derive"] + [dev-dependencies] tempfile = "3.27.0" + +[package] +edition = 2024 +name = "mould" +version = "0.4.0" + +[package.authors] +"" = ["Nils Pukropp "] diff --git a/Cargo.toml.out b/Cargo.toml.out deleted file mode 100644 index 8a1a38f..0000000 --- a/Cargo.toml.out +++ /dev/null @@ -1,33 +0,0 @@ -[[bin]] -name = "mould" -path = "src/main.rs" - -[dependencies] -anyhow = "1.0.102" -crossterm = "0.29.0" -dirs = "6.0.0" -env_logger = "0.11.9" -log = "0.4.29" -ratatui = "0.30.0" -serde_json = "1.0.149" -serde_yaml = "0.9.34" -thiserror = "2.0.18" -toml = "1.0.6" -tui-input = "0.15.0" - -[dependencies.clap] -features = ["derive"] -version = "4.6.0" - -[dependencies.serde] -features = ["derive"] -version = "1.0.228" - -[dev-dependencies] -tempfile = "3.27.0" - -[package] -authors = ["Nils Pukropp "] -edition = 2024 -name = "mould" -version = "0.3.0" diff --git a/README.md b/README.md index 723bbbc..345cb0c 100644 --- a/README.md +++ b/README.md @@ -5,17 +5,18 @@ mould is a modern Terminal User Interface (TUI) tool designed for interactively ## Features - **Universal Format Support**: Handle `.env`, `JSON`, `YAML`, and `TOML` seamlessly. -- **Hierarchical Flattening**: Edit nested data structures (JSON, YAML, TOML) in a flat, searchable list. +- **Tree View Navigation**: Edit nested data structures (JSON, YAML, TOML) in a beautiful, depth-colored tree view. +- **Smart Template Comparison**: Automatically discovers `.env.example` vs `.env` relationships and highlights missing or modified keys. +- **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. - **Docker Compose Integration**: Automatically generate `docker-compose.override.yml` from `docker-compose.yml`. -- **Vim-inspired Workflow**: Navigate with `j`/`k`, edit with `i`, and save with `:w`. -- **Modern UI**: A polished, rounded interface featuring the Catppuccin Mocha palette. -- **Highly Configurable**: Customize keybindings and themes via a simple TOML configuration. -- **Dynamic Alignment**: Automatically aligns keys and values for perfect vertical readability. -- **Default Value Visibility**: Keep track of original template values while editing. -- **Incremental Merging**: Load existing values from an output file to continue where you left off. +- **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. +- **Highly Configurable**: Customize keybindings and semantic themes via a simple TOML configuration. ## Installation +### CLI Application Ensure you have Rust and Cargo installed, then run: ```sh @@ -30,11 +31,21 @@ cd mould cargo build --release ``` -The binary will be installed as `mould`. +### Neovim Plugin +If you use a plugin manager like `lazy.nvim`, you can add the local repository (or remote once published) directly: + +```lua +{ + "username/mould", -- replace with actual repo path or github url + config = function() + -- Provides the :Mould command + end +} +``` ## Usage -Provide an input template file to start editing: +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. ```sh mould .env.example @@ -47,7 +58,10 @@ mould config.template.json -o config.json - **Normal Mode** - `j` / `Down`: Move selection down - `k` / `Up`: Move selection up + - `gg`: Jump to the top + - `G`: Jump to the bottom - `i`: Edit the value of the currently selected key (Enter Insert Mode) + - `a`: Add missing value from template to active config - `/`: Search for configuration keys (Jump to matches) - `n`: Jump to the next search match - `N`: Jump to the previous search match @@ -75,23 +89,35 @@ edit = "i" save = ":w" quit = ":q" normal_mode = "Esc" +search = "/" +next_match = "n" +previous_match = "N" +jump_top = "gg" +jump_bottom = "G" [theme] # Enable transparency to let your terminal background show through transparent = false -# Custom color palette (Catppuccin Mocha defaults) -crust = "#11111b" -surface0 = "#313244" -surface1 = "#45475a" -text = "#cdd6f4" -blue = "#89b4fa" -green = "#a6e3a1" -lavender = "#b4befe" -mauve = "#cba6f7" -peach = "#fab387" +# Custom color palette (Semantic Catppuccin Mocha defaults) +bg_normal = "#1e1e2e" +bg_highlight = "#89b4fa" +bg_active = "#a6e3a1" +bg_search = "#cba6f7" +fg_normal = "#cdd6f4" +fg_dimmed = "#6c7086" +fg_highlight = "#1e1e2e" +fg_warning = "#f38ba8" +fg_modified = "#fab387" +fg_accent = "#b4befe" +border_normal = "#45475a" +border_active = "#a6e3a1" +tree_depth_1 = "#b4befe" +tree_depth_2 = "#cba6f7" +tree_depth_3 = "#89b4fa" +tree_depth_4 = "#fab387" ``` ## License -This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. +This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. \ No newline at end of file diff --git a/config.example.toml b/config.example.toml index 7a9be3f..829053a 100644 --- a/config.example.toml +++ b/config.example.toml @@ -7,34 +7,23 @@ transparent = false # Colors are specified in hex format ("#RRGGBB"). -# Default values follow the Catppuccin Mocha palette. +# Default values follow the Semantic Catppuccin Mocha palette. -# Main background color (used when transparent = false). -crust = "#11111b" - -# Status bar background and other secondary UI elements. -surface0 = "#313244" - -# Border and separator color. -surface1 = "#45475a" - -# Default text color. -text = "#cdd6f4" - -# Selection highlight and "NORMAL" mode tag color. -blue = "#89b4fa" - -# Insert mode highlight and "INSERT" mode tag color. -green = "#a6e3a1" - -# Keys/labels color. -lavender = "#b4befe" - -# Section titles color. -mauve = "#cba6f7" - -# Input field titles color. -peach = "#fab387" +bg_normal = "#11111b" +bg_highlight = "#89b4fa" +bg_active = "#a6e3a1" +bg_search = "#cba6f7" +fg_normal = "#cdd6f4" +fg_dimmed = "#a6adc8" +fg_highlight = "#11111b" +fg_warning = "#f38ba8" +fg_modified = "#fab387" +border_normal = "#45475a" +border_active = "#a6e3a1" +tree_depth_1 = "#b4befe" +tree_depth_2 = "#cba6f7" +tree_depth_3 = "#89b4fa" +tree_depth_4 = "#fab387" [keybinds] # Keybindings for navigation and application control. @@ -47,3 +36,5 @@ normal_mode = "Esc" search = "/" next_match = "n" previous_match = "N" +jump_top = "gg" +jump_bottom = "G" diff --git a/lua/mould/init.lua b/lua/mould/init.lua new file mode 100644 index 0000000..ab05545 --- /dev/null +++ b/lua/mould/init.lua @@ -0,0 +1,54 @@ +local M = {} + +local function open_floating_terminal(cmd) + local buf = vim.api.nvim_create_buf(false, true) + local width = math.floor(vim.o.columns * 0.9) + local height = math.floor(vim.o.lines * 0.9) + local col = math.floor((vim.o.columns - width) / 2) + local row = math.floor((vim.o.lines - height) / 2) + + local win_config = { + relative = "editor", + width = width, + height = height, + col = col, + row = row, + style = "minimal", + border = "rounded", + } + + local win = vim.api.nvim_open_win(buf, true, win_config) + + -- Record the original buffer to reload it later + local original_buf = vim.api.nvim_get_current_buf() + local original_file = vim.api.nvim_buf_get_name(original_buf) + + vim.fn.termopen(cmd, { + on_exit = function() + vim.api.nvim_win_close(win, true) + vim.api.nvim_buf_delete(buf, { force = true }) + + -- Reload the original file if it exists + if vim.fn.filereadable(original_file) == 1 then + vim.schedule(function() + vim.cmd("checktime " .. vim.fn.fnameescape(original_file)) + end) + end + end, + }) + + vim.cmd("startinsert") +end + +function M.open() + local filepath = vim.api.nvim_buf_get_name(0) + if filepath == "" then + vim.notify("mould.nvim: Cannot open mould for an unnamed buffer.", vim.log.levels.ERROR) + return + end + + local cmd = string.format("mould %s", vim.fn.shellescape(filepath)) + open_floating_terminal(cmd) +end + +return M diff --git a/plugin/mould.lua b/plugin/mould.lua new file mode 100644 index 0000000..6a1b5f4 --- /dev/null +++ b/plugin/mould.lua @@ -0,0 +1,8 @@ +if vim.g.loaded_mould == 1 then + return +end +vim.g.loaded_mould = 1 + +vim.api.nvim_create_user_command("Mould", function() + require("mould").open() +end, { desc = "Open mould for the current buffer" }) diff --git a/src/app.rs b/src/app.rs index 2fba0f1..6e10a6a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,4 +1,4 @@ -use crate::format::EnvVar; +use crate::format::ConfigItem; use tui_input::Input; /// Represents the current operating mode of the application. @@ -14,7 +14,7 @@ pub enum Mode { /// The core application state, holding all configuration variables and UI status. pub struct App { /// The list of configuration variables being edited. - pub vars: Vec, + pub vars: Vec, /// Index of the currently selected variable in the list. pub selected: usize, /// The current interaction mode (Normal or Insert). @@ -31,8 +31,8 @@ pub struct App { impl App { /// Initializes a new application instance with the provided variables. - pub fn new(vars: Vec) -> Self { - let initial_input = vars.get(0).map(|v| v.value.clone()).unwrap_or_default(); + pub fn new(vars: Vec) -> Self { + let initial_input = vars.get(0).and_then(|v| v.value.clone()).unwrap_or_default(); Self { vars, selected: 0, @@ -78,6 +78,22 @@ impl App { } } + /// Jumps to the top of the list. + pub fn jump_top(&mut self) { + if !self.vars.is_empty() { + self.selected = 0; + self.sync_input_with_selected(); + } + } + + /// Jumps to the bottom of the list. + pub fn jump_bottom(&mut self) { + if !self.vars.is_empty() { + self.selected = self.vars.len() - 1; + self.sync_input_with_selected(); + } + } + /// Jumps to the next variable that matches the search query. pub fn jump_next_match(&mut self) { let indices = self.matching_indices(); @@ -118,21 +134,29 @@ impl App { /// Updates the input buffer to reflect the value of the currently selected variable. pub fn sync_input_with_selected(&mut self) { if let Some(var) = self.vars.get(self.selected) { - self.input = Input::new(var.value.clone()); + let val = var.value.clone().unwrap_or_default(); + self.input = Input::new(val); } } /// Commits the current text in the input buffer back to the selected variable's value. pub fn commit_input(&mut self) { if let Some(var) = self.vars.get_mut(self.selected) { - var.value = self.input.value().to_string(); + if !var.is_group { + var.value = Some(self.input.value().to_string()); + var.status = crate::format::ItemStatus::Modified; + } } } /// Transitions the application into Insert Mode. pub fn enter_insert(&mut self) { - self.mode = Mode::Insert; - self.status_message = None; + if let Some(var) = self.vars.get(self.selected) { + if !var.is_group { + self.mode = Mode::Insert; + self.status_message = None; + } + } } /// Commits the current input and transitions the application into Normal Mode. diff --git a/src/config.rs b/src/config.rs index 07badb3..d05f87c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -8,24 +8,38 @@ use std::fs; pub struct ThemeConfig { /// If true, skip rendering the background block to let the terminal's transparency show. pub transparent: bool, - /// Color for standard background areas (when not transparent). - pub crust: String, - /// Dark surface color for UI elements like the status bar. - pub surface0: String, - /// Light surface color for borders and dividers. - pub surface1: String, - /// Default text color. - pub text: String, - /// Color for selection highlighting and normal mode tag. - pub blue: String, - /// Color for insert mode highlighting and success status. - pub green: String, - /// Accent color for configuration keys. - pub lavender: String, - /// Accent color for primary section titles. - pub mauve: String, - /// Accent color for input field titles. - pub peach: String, + /// Default background. + pub bg_normal: String, + /// Background for selected items and standard UI elements. + pub bg_highlight: String, + /// Active element background (e.g., insert mode). + pub bg_active: String, + /// Active element background (e.g., search mode). + pub bg_search: String, + /// Standard text. + pub fg_normal: String, + /// Secondary/inactive text. + pub fg_dimmed: String, + /// Text on selected items. + pub fg_highlight: String, + /// Red/Alert color for missing items. + pub fg_warning: String, + /// Accent color for modified items. + pub fg_modified: String, + /// High-contrast accent for titles and active UI elements. + pub fg_accent: String, + /// Borders. + pub border_normal: String, + /// Active borders (e.g., input mode). + pub border_active: String, + /// Color for tree indentation depth 1. + pub tree_depth_1: String, + /// Color for tree indentation depth 2. + pub tree_depth_2: String, + /// Color for tree indentation depth 3. + pub tree_depth_3: String, + /// Color for tree indentation depth 4. + pub tree_depth_4: String, } impl ThemeConfig { @@ -42,49 +56,45 @@ impl ThemeConfig { } } - pub fn crust(&self) -> Color { - Self::parse_hex(&self.crust) - } - pub fn surface0(&self) -> Color { - Self::parse_hex(&self.surface0) - } - pub fn surface1(&self) -> Color { - Self::parse_hex(&self.surface1) - } - pub fn text(&self) -> Color { - Self::parse_hex(&self.text) - } - pub fn blue(&self) -> Color { - Self::parse_hex(&self.blue) - } - pub fn green(&self) -> Color { - Self::parse_hex(&self.green) - } - pub fn lavender(&self) -> Color { - Self::parse_hex(&self.lavender) - } - pub fn mauve(&self) -> Color { - Self::parse_hex(&self.mauve) - } - pub fn peach(&self) -> Color { - Self::parse_hex(&self.peach) - } + pub fn bg_normal(&self) -> Color { Self::parse_hex(&self.bg_normal) } + pub fn bg_highlight(&self) -> Color { Self::parse_hex(&self.bg_highlight) } + pub fn bg_active(&self) -> Color { Self::parse_hex(&self.bg_active) } + pub fn bg_search(&self) -> Color { Self::parse_hex(&self.bg_search) } + pub fn fg_normal(&self) -> Color { Self::parse_hex(&self.fg_normal) } + pub fn fg_dimmed(&self) -> Color { Self::parse_hex(&self.fg_dimmed) } + pub fn fg_highlight(&self) -> Color { Self::parse_hex(&self.fg_highlight) } + pub fn fg_warning(&self) -> Color { Self::parse_hex(&self.fg_warning) } + pub fn fg_modified(&self) -> Color { Self::parse_hex(&self.fg_modified) } + pub fn fg_accent(&self) -> Color { Self::parse_hex(&self.fg_accent) } + pub fn border_normal(&self) -> Color { Self::parse_hex(&self.border_normal) } + pub fn border_active(&self) -> Color { Self::parse_hex(&self.border_active) } + pub fn tree_depth_1(&self) -> Color { Self::parse_hex(&self.tree_depth_1) } + pub fn tree_depth_2(&self) -> Color { Self::parse_hex(&self.tree_depth_2) } + pub fn tree_depth_3(&self) -> Color { Self::parse_hex(&self.tree_depth_3) } + pub fn tree_depth_4(&self) -> Color { Self::parse_hex(&self.tree_depth_4) } } impl Default for ThemeConfig { - /// Default theme: Catppuccin Mocha. + /// Default theme: Semantic Catppuccin Mocha. fn default() -> Self { Self { transparent: false, - crust: "#11111b".to_string(), - surface0: "#313244".to_string(), - surface1: "#45475a".to_string(), - text: "#cdd6f4".to_string(), - blue: "#89b4fa".to_string(), - green: "#a6e3a1".to_string(), - lavender: "#b4befe".to_string(), - mauve: "#cba6f7".to_string(), - peach: "#fab387".to_string(), + bg_normal: "#1e1e2e".to_string(), // base + bg_highlight: "#89b4fa".to_string(), // blue + bg_active: "#a6e3a1".to_string(), // green + bg_search: "#cba6f7".to_string(), // mauve + fg_normal: "#cdd6f4".to_string(), // text + fg_dimmed: "#6c7086".to_string(), // overlay0 + fg_highlight: "#1e1e2e".to_string(), // base (dark for contrast against highlights) + fg_warning: "#f38ba8".to_string(), // red + fg_modified: "#fab387".to_string(), // peach + fg_accent: "#b4befe".to_string(), // lavender + border_normal: "#45475a".to_string(), // surface1 + border_active: "#a6e3a1".to_string(), // green + tree_depth_1: "#b4befe".to_string(), // lavender + tree_depth_2: "#cba6f7".to_string(), // mauve + tree_depth_3: "#89b4fa".to_string(), // blue + tree_depth_4: "#fab387".to_string(), // peach } } } @@ -102,6 +112,8 @@ pub struct KeybindsConfig { pub search: String, pub next_match: String, pub previous_match: String, + pub jump_top: String, + pub jump_bottom: String, } impl Default for KeybindsConfig { @@ -116,6 +128,8 @@ impl Default for KeybindsConfig { search: "/".to_string(), next_match: "n".to_string(), previous_match: "N".to_string(), + jump_top: "gg".to_string(), + jump_bottom: "G".to_string(), } } } diff --git a/src/format/env.rs b/src/format/env.rs index cb24fdf..7852ea3 100644 --- a/src/format/env.rs +++ b/src/format/env.rs @@ -1,4 +1,4 @@ -use super::{EnvVar, FormatHandler}; +use super::{ConfigItem, FormatHandler, ItemStatus}; use std::fs; use std::io::{self, Write}; use std::path::Path; @@ -6,7 +6,7 @@ use std::path::Path; pub struct EnvHandler; impl FormatHandler for EnvHandler { - fn parse(&self, path: &Path) -> io::Result> { + fn parse(&self, path: &Path) -> io::Result> { let content = fs::read_to_string(path)?; let mut vars = Vec::new(); @@ -18,10 +18,15 @@ impl FormatHandler for EnvHandler { if let Some((key, val)) = line.split_once('=') { let parsed_val = val.trim().trim_matches('"').trim_matches('\'').to_string(); - vars.push(EnvVar { + vars.push(ConfigItem { key: key.trim().to_string(), - value: parsed_val.clone(), - default_value: parsed_val, + path: key.trim().to_string(), + value: Some(parsed_val.clone()), + template_value: Some(parsed_val.clone()), + default_value: Some(parsed_val), + depth: 0, + is_group: false, + status: ItemStatus::Present, }); } } @@ -29,7 +34,7 @@ impl FormatHandler for EnvHandler { Ok(vars) } - fn merge(&self, path: &Path, vars: &mut Vec) -> io::Result<()> { + fn merge(&self, path: &Path, vars: &mut Vec) -> io::Result<()> { if !path.exists() { return Ok(()); } @@ -46,24 +51,44 @@ impl FormatHandler for EnvHandler { let parsed_val = val.trim().trim_matches('"').trim_matches('\'').to_string(); if let Some(var) = vars.iter_mut().find(|v| v.key == key) { - var.value = parsed_val; + if var.value.as_deref() != Some(&parsed_val) { + var.value = Some(parsed_val); + var.status = ItemStatus::Modified; + } } else { - vars.push(EnvVar { + vars.push(ConfigItem { key: key.to_string(), - value: parsed_val.clone(), - default_value: String::new(), + path: key.to_string(), + value: Some(parsed_val), + template_value: None, + default_value: None, + depth: 0, + is_group: false, + status: ItemStatus::MissingFromTemplate, }); } } } + + // Mark missing from active + for var in vars.iter_mut() { + if var.status == ItemStatus::Present && var.value.is_none() { + var.status = ItemStatus::MissingFromActive; + } + } Ok(()) } - fn write(&self, path: &Path, vars: &[EnvVar]) -> io::Result<()> { + fn write(&self, path: &Path, vars: &[ConfigItem]) -> io::Result<()> { let mut file = fs::File::create(path)?; for var in vars { - writeln!(file, "{}={}", var.key, var.value)?; + if !var.is_group { + let val = var.value.as_deref() + .or(var.template_value.as_deref()) + .unwrap_or(""); + writeln!(file, "{}={}", var.key, val)?; + } } Ok(()) } @@ -88,14 +113,13 @@ mod tests { let vars = handler.parse(file.path()).unwrap(); assert_eq!(vars.len(), 4); assert_eq!(vars[0].key, "KEY1"); - assert_eq!(vars[0].value, "value1"); - assert_eq!(vars[0].default_value, "value1"); + assert_eq!(vars[0].value.as_deref(), Some("value1")); assert_eq!(vars[1].key, "KEY2"); - assert_eq!(vars[1].value, "value2"); + assert_eq!(vars[1].value.as_deref(), Some("value2")); assert_eq!(vars[2].key, "KEY3"); - assert_eq!(vars[2].value, "value3"); + assert_eq!(vars[2].value.as_deref(), Some("value3")); assert_eq!(vars[3].key, "EMPTY"); - assert_eq!(vars[3].value, ""); + assert_eq!(vars[3].value.as_deref(), Some("")); } #[test] @@ -112,25 +136,29 @@ mod tests { assert_eq!(vars.len(), 3); assert_eq!(vars[0].key, "KEY1"); - assert_eq!(vars[0].value, "custom1"); - assert_eq!(vars[0].default_value, "default1"); + assert_eq!(vars[0].value.as_deref(), Some("custom1")); + assert_eq!(vars[0].status, ItemStatus::Modified); assert_eq!(vars[1].key, "KEY2"); - assert_eq!(vars[1].value, "default2"); - assert_eq!(vars[1].default_value, "default2"); - + assert_eq!(vars[1].value.as_deref(), Some("default2")); + assert_eq!(vars[2].key, "KEY3"); - assert_eq!(vars[2].value, "custom3"); - assert_eq!(vars[2].default_value, ""); + assert_eq!(vars[2].value.as_deref(), Some("custom3")); + assert_eq!(vars[2].status, ItemStatus::MissingFromTemplate); } #[test] fn test_write_env() { let file = NamedTempFile::new().unwrap(); - let vars = vec![EnvVar { + let vars = vec![ConfigItem { key: "KEY1".to_string(), - value: "value1".to_string(), - default_value: "def".to_string(), + path: "KEY1".to_string(), + value: Some("value1".to_string()), + template_value: None, + default_value: None, + depth: 0, + is_group: false, + status: ItemStatus::Present, }]; let handler = EnvHandler; diff --git a/src/format/hierarchical.rs b/src/format/hierarchical.rs index d124e58..4488aa7 100644 --- a/src/format/hierarchical.rs +++ b/src/format/hierarchical.rs @@ -1,4 +1,4 @@ -use super::{EnvVar, FormatHandler, FormatType}; +use super::{ConfigItem, FormatHandler, FormatType, ItemStatus}; use serde_json::{Map, Value}; use std::fs; use std::io; @@ -53,87 +53,160 @@ impl HierarchicalHandler { } } -fn flatten(value: &Value, prefix: &str, vars: &mut Vec) { +fn flatten(value: &Value, prefix: &str, depth: usize, key_name: &str, vars: &mut Vec) { + let path = if prefix.is_empty() { + key_name.to_string() + } else if key_name.is_empty() { + prefix.to_string() + } else { + format!("{}.{}", prefix, key_name) + }; + match value { Value::Object(map) => { + if !path.is_empty() { + vars.push(ConfigItem { + key: key_name.to_string(), + path: path.clone(), + value: None, + template_value: None, + default_value: None, + depth, + is_group: true, + status: ItemStatus::Present, + }); + } + let next_depth = if path.is_empty() { depth } else { depth + 1 }; for (k, v) in map { - let new_prefix = if prefix.is_empty() { - k.clone() - } else { - format!("{}.{}", prefix, k) - }; - flatten(v, &new_prefix, vars); + flatten(v, &path, next_depth, k, vars); } } Value::Array(arr) => { + if !path.is_empty() { + vars.push(ConfigItem { + key: key_name.to_string(), + path: path.clone(), + value: None, + template_value: None, + default_value: None, + depth, + is_group: true, + status: ItemStatus::Present, + }); + } + let next_depth = if path.is_empty() { depth } else { depth + 1 }; for (i, v) in arr.iter().enumerate() { - let new_prefix = format!("{}[{}]", prefix, i); - flatten(v, &new_prefix, vars); + let array_key = format!("[{}]", i); + flatten(v, &path, next_depth, &array_key, vars); } } Value::String(s) => { - vars.push(EnvVar { - key: prefix.to_string(), - value: s.clone(), - default_value: s.clone(), + vars.push(ConfigItem { + key: key_name.to_string(), + path: path.clone(), + value: Some(s.clone()), + template_value: Some(s.clone()), + default_value: Some(s.clone()), + depth, + is_group: false, + status: ItemStatus::Present, }); } Value::Number(n) => { let s = n.to_string(); - vars.push(EnvVar { - key: prefix.to_string(), - value: s.clone(), - default_value: s.clone(), + vars.push(ConfigItem { + key: key_name.to_string(), + path: path.clone(), + value: Some(s.clone()), + template_value: Some(s.clone()), + default_value: Some(s.clone()), + depth, + is_group: false, + status: ItemStatus::Present, }); } Value::Bool(b) => { let s = b.to_string(); - vars.push(EnvVar { - key: prefix.to_string(), - value: s.clone(), - default_value: s.clone(), + vars.push(ConfigItem { + key: key_name.to_string(), + path: path.clone(), + value: Some(s.clone()), + template_value: Some(s.clone()), + default_value: Some(s.clone()), + depth, + is_group: false, + status: ItemStatus::Present, }); } Value::Null => { - vars.push(EnvVar { - key: prefix.to_string(), - value: "".to_string(), - default_value: "".to_string(), + vars.push(ConfigItem { + key: key_name.to_string(), + path: path.clone(), + value: Some("".to_string()), + template_value: Some("".to_string()), + default_value: Some("".to_string()), + depth, + is_group: false, + status: ItemStatus::Present, }); } } } -// Removed unused update_leaf and update_leaf_value functions - impl FormatHandler for HierarchicalHandler { - fn parse(&self, path: &Path) -> io::Result> { + fn parse(&self, path: &Path) -> io::Result> { let value = self.read_value(path)?; let mut vars = Vec::new(); - flatten(&value, "", &mut vars); + flatten(&value, "", 0, "", &mut vars); Ok(vars) } - fn merge(&self, path: &Path, vars: &mut Vec) -> io::Result<()> { + fn merge(&self, path: &Path, vars: &mut Vec) -> io::Result<()> { if !path.exists() { return Ok(()); } let existing_value = self.read_value(path)?; let mut existing_vars = Vec::new(); - flatten(&existing_value, "", &mut existing_vars); + flatten(&existing_value, "", 0, "", &mut existing_vars); for var in vars.iter_mut() { - if let Some(existing) = existing_vars.iter().find(|v| v.key == var.key) { - var.value = existing.value.clone(); + if let Some(existing) = existing_vars.iter().find(|v| v.path == var.path) { + if var.value != existing.value { + var.value = existing.value.clone(); + var.status = ItemStatus::Modified; + } + } else { + var.status = ItemStatus::MissingFromActive; } } + + // Find keys in active that are not in template + let mut to_add = Vec::new(); + for existing in existing_vars { + if !vars.iter().any(|v| v.path == existing.path) { + let mut new_item = existing.clone(); + new_item.status = ItemStatus::MissingFromTemplate; + new_item.template_value = None; + new_item.default_value = None; + to_add.push(new_item); + } + } + + // Basic insertion logic for extra keys (could be improved to insert at correct depth/position) + vars.extend(to_add); + Ok(()) } - fn write(&self, path: &Path, vars: &[EnvVar]) -> io::Result<()> { + fn write(&self, path: &Path, vars: &[ConfigItem]) -> io::Result<()> { let mut root = Value::Object(Map::new()); for var in vars { - insert_into_value(&mut root, &var.key, &var.value); + if !var.is_group { + let val = var.value.as_deref() + .or(var.template_value.as_deref()) + .unwrap_or(""); + insert_into_value(&mut root, &var.path, val); + } } self.write_value(path, &root) } diff --git a/src/format/mod.rs b/src/format/mod.rs index d8814ab..b444db6 100644 --- a/src/format/mod.rs +++ b/src/format/mod.rs @@ -4,11 +4,24 @@ use std::path::Path; pub mod env; pub mod hierarchical; +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ItemStatus { + Present, + MissingFromActive, + MissingFromTemplate, + Modified, +} + #[derive(Debug, Clone)] -pub struct EnvVar { +pub struct ConfigItem { pub key: String, - pub value: String, - pub default_value: String, + pub path: String, + pub value: Option, + pub template_value: Option, + pub default_value: Option, + pub depth: usize, + pub is_group: bool, + pub status: ItemStatus, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -20,9 +33,9 @@ pub enum FormatType { } pub trait FormatHandler { - fn parse(&self, path: &Path) -> io::Result>; - fn merge(&self, path: &Path, vars: &mut Vec) -> io::Result<()>; - fn write(&self, path: &Path, vars: &[EnvVar]) -> io::Result<()>; + fn parse(&self, path: &Path) -> io::Result>; + fn merge(&self, path: &Path, vars: &mut Vec) -> io::Result<()>; + fn write(&self, path: &Path, vars: &[ConfigItem]) -> io::Result<()>; } pub fn detect_format(path: &Path, override_format: Option) -> FormatType { diff --git a/src/main.rs b/src/main.rs index c1f1eb7..3b59d64 100644 --- a/src/main.rs +++ b/src/main.rs @@ -171,23 +171,74 @@ fn main() -> anyhow::Result<()> { let format_type = detect_format(&input_path, args.format); let handler = get_handler(format_type); + // Smart Comparison Logic + let input_name = input_path.file_name().unwrap_or_default().to_string_lossy(); + let is_template_input = input_name.contains(".example") || input_name.contains(".template") || input_name == "compose.yml" || input_name == "docker-compose.yml"; + + let mut template_path = None; + let mut active_path = None; + + if is_template_input { + template_path = Some(input_path.clone()); + let expected_active = determine_output_path(&input_path); + if expected_active.exists() { + active_path = Some(expected_active); + } + } else { + // Input is likely an active config (e.g., .env) + active_path = Some(input_path.clone()); + // Try to find a template + let possible_templates = [ + format!("{}.example", input_name), + format!("{}.template", input_name), + ]; + for t in possible_templates { + let p = input_path.with_file_name(t); + if p.exists() { + template_path = Some(p); + break; + } + } + } + let output_path = args .output - .unwrap_or_else(|| determine_output_path(&input_path)); + .unwrap_or_else(|| active_path.clone().unwrap_or_else(|| determine_output_path(&input_path))); info!("Output: {}", output_path.display()); - let mut vars = handler.parse(&input_path).map_err(|e| { - error!("Failed to parse input file: {}", e); - MouldError::Format(format!("Failed to parse {}: {}", input_path.display(), e)) - })?; + // 1. Load active config if it exists + let mut vars = if let Some(active) = &active_path { + handler.parse(active).unwrap_or_default() + } else { + Vec::new() + }; - if vars.is_empty() { - warn!("No variables found in {}", input_path.display()); + // 2. Load template config and merge + if let Some(template) = &template_path { + info!("Comparing with template: {}", template.display()); + let template_vars = handler.parse(template).unwrap_or_default(); + if vars.is_empty() { + vars = template_vars; + // If we only have template, everything is missing from active initially + for v in vars.iter_mut() { + v.status = crate::format::ItemStatus::MissingFromActive; + v.value = None; + } + } else { + // Merge template into active + handler.merge(template, &mut vars).unwrap_or_default(); + } + } else if vars.is_empty() { + // Fallback if no template and active is empty + vars = handler.parse(&input_path).map_err(|e| { + error!("Failed to parse input file: {}", e); + MouldError::Format(format!("Failed to parse {}: {}", input_path.display(), e)) + })?; } - if let Err(e) = handler.merge(&output_path, &mut vars) { - warn!("Could not merge existing output file: {}", e); + if vars.is_empty() { + warn!("No variables found."); } let config = load_config(); diff --git a/src/runner.rs b/src/runner.rs index 6ee8107..a91df22 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -22,6 +22,8 @@ pub struct AppRunner<'a, B: Backend> { handler: &'a dyn FormatHandler, /// Buffer for storing active command entry (e.g., ":w"). command_buffer: String, + /// Buffer for storing sequence of key presses (e.g., "gg"). + key_sequence: String, } impl<'a, B: Backend> AppRunner<'a, B> @@ -43,6 +45,7 @@ where output_path, handler, command_buffer: String::new(), + key_sequence: String::new(), } } @@ -107,38 +110,90 @@ where /// Handles primary navigation (j/k) and transitions to insert or command modes. fn handle_navigation_mode(&mut self, key: KeyEvent) -> io::Result<()> { if let KeyCode::Char(c) = key.code { - let c_str = c.to_string(); - if c_str == self.config.keybinds.down { - self.app.next(); - } else if c_str == self.config.keybinds.up { - self.app.previous(); - } else if c_str == self.config.keybinds.edit { - self.app.enter_insert(); - } else if c_str == ":" { - self.command_buffer.push(':'); - self.sync_command_status(); - } else if c_str == "q" { - self.app.running = false; - } else if c_str == self.config.keybinds.search { - self.app.mode = Mode::Search; - self.app.search_query.clear(); - self.app.status_message = Some(format!("{} ", self.config.keybinds.search)); - } else if c_str == self.config.keybinds.next_match { - self.app.jump_next_match(); - } else if c_str == self.config.keybinds.previous_match { - self.app.jump_previous_match(); + self.key_sequence.push(c); + + // Collect all configured keybinds + let binds = [ + (&self.config.keybinds.down, "down"), + (&self.config.keybinds.up, "up"), + (&self.config.keybinds.edit, "edit"), + (&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"), + (&"a".to_string(), "add_missing"), + (&":".to_string(), "command"), + (&"q".to_string(), "quit"), + ]; + + let mut exact_match = None; + let mut prefix_match = false; + + 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 { + self.key_sequence.clear(); + match action { + "down" => self.app.next(), + "up" => self.app.previous(), + "edit" => self.app.enter_insert(), + "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(), + "add_missing" => self.add_missing_item(), + "command" => { + self.command_buffer.push(':'); + self.sync_command_status(); + } + "quit" => self.app.running = false, + _ => {} + } + } 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.push(c); } } else { + // Non-character keys reset the sequence buffer + 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, _ => {} } } Ok(()) } + /// 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) { + if 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(); + } + } + } + /// Delegates key events to the `tui_input` handler during active editing. fn handle_insert_mode(&mut self, key: KeyEvent) -> io::Result<()> { match key.code { diff --git a/src/ui.rs b/src/ui.rs index b92efe4..ef584a4 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -16,7 +16,7 @@ pub fn draw(f: &mut Frame, app: &mut App, config: &Config) { // Render the main background (optional based on transparency config). if !theme.transparent { f.render_widget( - Block::default().style(Style::default().bg(theme.crust())), + Block::default().style(Style::default().bg(theme.bg_normal())), size, ); } @@ -53,76 +53,116 @@ pub fn draw(f: &mut Frame, app: &mut App, config: &Config) { let is_selected = i == app.selected; let is_match = matching_indices.contains(&i); - // Show live input text for the selected item if in Insert mode. - let val = if is_selected && matches!(app.mode, Mode::Insert) { - app.input.value() + // Indentation based on depth + let indent = " ".repeat(var.depth); + let prefix = if var.is_group { "+ " } else { " " }; + + // Determine colors based on depth + let depth_color = if is_selected { + theme.bg_normal() } else { - &var.value + match var.depth % 4 { + 0 => theme.tree_depth_1(), + 1 => theme.tree_depth_2(), + 2 => theme.tree_depth_3(), + 3 => theme.tree_depth_4(), + _ => theme.fg_normal(), + } + }; + + // Determine colors based on status and selection + let text_color = if is_selected { + theme.fg_highlight() + } else { + match var.status { + crate::format::ItemStatus::MissingFromActive if !var.is_group => theme.fg_dimmed(), + crate::format::ItemStatus::Modified => theme.fg_modified(), + _ => theme.fg_normal(), + } }; let key_style = if is_selected { Style::default() - .fg(theme.crust()) + .fg(theme.fg_highlight()) .add_modifier(Modifier::BOLD) } else if is_match { Style::default() - .fg(theme.mauve()) + .fg(theme.bg_search()) .add_modifier(Modifier::UNDERLINED) + } else if var.status == crate::format::ItemStatus::MissingFromActive && !var.is_group { + Style::default() + .fg(theme.fg_dimmed()) + .add_modifier(Modifier::DIM) } else { - Style::default().fg(theme.lavender()) + Style::default().fg(depth_color) }; - let value_style = if is_selected { - Style::default().fg(theme.crust()) - } else { - Style::default().fg(theme.text()) - }; + let mut key_spans = vec![ + Span::raw(indent), + Span::styled(prefix, Style::default().fg(theme.border_normal())), + Span::styled(&var.key, key_style), + ]; - // Path styling for nested keys (e.g., a.b.c) - let mut key_spans = Vec::new(); - if let Some(last_dot) = var.key.rfind('.') { - let path = &var.key[..=last_dot]; - let key = &var.key[last_dot + 1..]; - - let path_style = if is_selected { - Style::default() - .fg(theme.crust()) - .add_modifier(Modifier::DIM) - } else { - Style::default().fg(theme.surface1()) - }; - - key_spans.push(Span::styled(path, path_style)); - key_spans.push(Span::styled(key, key_style)); - } else { - key_spans.push(Span::styled(&var.key, key_style)); + // Add status indicator if not present (only for leaf variables) + if !var.is_group { + match var.status { + crate::format::ItemStatus::MissingFromActive => { + 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)", 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 { - Style::default().bg(theme.blue()) + Style::default().bg(theme.bg_highlight()) } else { - Style::default().fg(theme.text()) + Style::default().fg(text_color) }; - // Two-line layout for better readability: - // Line 1: Key (path.key) - // Line 2: Value - let lines = vec![ - Line::from(key_spans), - Line::from(vec![ - Span::styled( - " └─ ", - if is_selected { - Style::default().fg(theme.crust()) - } else { - Style::default().fg(theme.surface1()) - }, - ), - Span::styled(val, value_style), - ]), - ]; + if var.is_group { + ListItem::new(Line::from(key_spans)).style(item_style) + } else { + // Show live input text for the selected item if in Insert mode. + let val = if is_selected && matches!(app.mode, Mode::Insert) { + app.input.value() + } else { + var.value.as_deref().unwrap_or("") + }; - ListItem::new(lines).style(item_style) + let value_style = if is_selected { + Style::default().fg(theme.fg_highlight()) + } else { + Style::default().fg(theme.fg_normal()) + }; + + let mut val_spans = vec![ + Span::raw(format!("{} └─ ", " ".repeat(var.depth))), + Span::styled(val, value_style), + ]; + + if let Some(t_val) = &var.template_value { + if Some(t_val) != var.value.as_ref() { + let t_style = if is_selected { + Style::default().fg(theme.bg_normal()).add_modifier(Modifier::DIM) + } else { + Style::default().fg(theme.fg_dimmed()).add_modifier(Modifier::ITALIC) + }; + val_spans.push(Span::styled(format!(" [Def: {}]", t_val), t_style)); + } + } + + ListItem::new(vec![Line::from(key_spans), Line::from(val_spans)]).style(item_style) + } }) .collect(); @@ -133,10 +173,10 @@ pub fn draw(f: &mut Frame, app: &mut App, config: &Config) { .title(" Config Variables ") .title_style( Style::default() - .fg(theme.mauve()) + .fg(theme.fg_accent()) .add_modifier(Modifier::BOLD), ) - .border_style(Style::default().fg(theme.surface1())), + .border_style(Style::default().fg(theme.border_normal())), ); let mut state = ListState::default(); @@ -145,26 +185,43 @@ pub fn draw(f: &mut Frame, app: &mut App, config: &Config) { // Render the focused input area. let current_var = app.vars.get(app.selected); - let input_title = if let Some(var) = current_var { - if var.default_value.is_empty() { - format!(" Editing: {} ", var.key) + let mut input_title = " Input ".to_string(); + let mut extra_info = String::new(); + + if let Some(var) = current_var { + if var.is_group { + input_title = format!(" Group: {} ", var.path); } else { - format!(" Editing: {} (Default: {}) ", var.key, var.default_value) + input_title = format!(" Editing: {} ", var.path); + if let Some(t_val) = &var.template_value { + extra_info = format!(" [Template: {}]", t_val); + } } - } else { - " Input ".to_string() - }; + } let input_border_color = match app.mode { - Mode::Insert => theme.green(), - Mode::Normal | Mode::Search => theme.surface1(), + Mode::Insert => theme.border_active(), + Mode::Normal | Mode::Search => theme.border_normal(), }; let input_text = app.input.value(); let cursor_pos = app.input.visual_cursor(); - let input = Paragraph::new(input_text) - .style(Style::default().fg(theme.text())) + // Show template value in normal mode if it differs + let display_text = if let Some(var) = current_var { + if var.is_group { + "".to_string() + } else if matches!(app.mode, Mode::Normal) { + format!("{}{}", input_text, extra_info) + } else { + input_text.to_string() + } + } else { + input_text.to_string() + }; + + let input = Paragraph::new(display_text) + .style(Style::default().fg(theme.fg_normal())) .block( Block::default() .borders(Borders::ALL) @@ -172,7 +229,7 @@ pub fn draw(f: &mut Frame, app: &mut App, config: &Config) { .title(input_title) .title_style( Style::default() - .fg(theme.peach()) + .fg(theme.fg_accent()) // Make title pop .add_modifier(Modifier::BOLD), ) .border_style(Style::default().fg(input_border_color)), @@ -192,22 +249,22 @@ pub fn draw(f: &mut Frame, app: &mut App, config: &Config) { Mode::Normal => ( " NORMAL ", Style::default() - .bg(theme.blue()) - .fg(theme.crust()) + .bg(theme.bg_highlight()) + .fg(theme.bg_normal()) .add_modifier(Modifier::BOLD), ), Mode::Insert => ( " INSERT ", Style::default() - .bg(theme.green()) - .fg(theme.crust()) + .bg(theme.bg_active()) + .fg(theme.bg_normal()) .add_modifier(Modifier::BOLD), ), Mode::Search => ( " SEARCH ", Style::default() - .bg(theme.mauve()) - .fg(theme.crust()) + .bg(theme.bg_search()) + .fg(theme.bg_normal()) .add_modifier(Modifier::BOLD), ), }; @@ -225,10 +282,10 @@ pub fn draw(f: &mut Frame, app: &mut App, config: &Config) { Span::styled(mode_str, mode_style), Span::styled( format!(" {} ", status_msg), - Style::default().bg(theme.surface0()).fg(theme.text()), + Style::default().bg(theme.border_normal()).fg(theme.fg_normal()), ), ]); - let status = Paragraph::new(status_line).style(Style::default().bg(theme.surface0())); + let status = Paragraph::new(status_line).style(Style::default().bg(theme.border_normal())); f.render_widget(status, chunks[4]); }