diff --git a/Cargo.toml b/Cargo.toml
index 2b648c6..b7da009 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -23,6 +23,8 @@ nucleo-matcher = "0.3.1"
tokio = {version="1.49.0", features=["full"]}
tokio-stream = {version="0.1.18", features = ["sync"]}
log4rs_test_utils = "0.2.3"
+yamlpatch = "0.11.0"
+yamlpath = "0.33.0"
# The profile that 'dist' will build with
[profile.dist]
diff --git a/docs/gui.md b/docs/gui.md
index f2b6b58..8960c2a 100644
--- a/docs/gui.md
+++ b/docs/gui.md
@@ -39,7 +39,7 @@ There are three main views:
1. *Help view*: Shows available commands.
-Use Tab to switch between the first two views, and Ctrl+H for help.
+Use Tab to switch between the first two views, and Ctrl+h for help.
* Enter: Go to selected directory
@@ -59,6 +59,8 @@ Use Tab to switch between the first two views, and Ctrl+H
* Ctrl+f Switch between exact and fuzzy search
+* F12: Open the configuration view
+
Also, you can simply type a string to filter directories history or shortcuts.
## Search
diff --git a/src/config.rs b/src/config.rs
index ae91e62..4d619ec 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -1,13 +1,23 @@
-use std::{env, fs, io::Write, path::PathBuf};
+use std::{
+ env, fs,
+ io::Write,
+ path::PathBuf,
+ sync::{Arc, OnceLock},
+};
use chrono::{DateTime, Local};
use log::{debug, error, info, trace};
use serde::{Deserialize, Serialize};
+use serde_yaml::Value;
+use yamlpatch::{Op, Patch, apply_yaml_patches};
+use yamlpath::route;
use crate::theme::{Theme, ThemeStyles};
pub(crate) const CDIR_CONFIG_VAR: &str = "CDIR_CONFIG";
+static CONFIG_FILE_PATH: OnceLock = OnceLock::new();
+
const DEFAULT_DB_PATH: fn() -> Option = || {
let mut path = dirs::data_dir().unwrap();
path.push("cdir");
@@ -41,8 +51,8 @@ const DEFAULT_THEME: fn() -> Option = || Some(String::from("default"));
const DEFAULT_COLORS: fn() -> Theme = || serde_yaml::from_str("").unwrap();
-const DEFAULT_DATE_FORMATER: fn() -> Box String> =
- || Box::from(|_| String::from(""));
+const DEFAULT_DATE_FORMATER: fn() -> Arc String + Send + Sync> =
+ || Arc::from(|_| String::from(""));
const DEFAULT_NONE: fn() -> Option = || None;
@@ -99,13 +109,9 @@ pub struct Config {
pub styles: ThemeStyles,
#[serde(skip, default = "DEFAULT_DATE_FORMATER")]
- pub date_formater: Box String>,
+ pub date_formater: Arc String + Send + Sync>,
}
-// Not really true, but good enough for our use case as it is the case after initialization (immutable config)
-unsafe impl Send for Config {}
-unsafe impl Sync for Config {}
-
impl Config {
fn build_config_file_path(config_file_path: Option) -> PathBuf {
if let Some(path) = config_file_path {
@@ -125,7 +131,7 @@ impl Config {
path
}
- pub fn load(config_file_path: Option) -> Result {
+ pub fn initialize_and_load(config_file_path: Option) -> Result {
let path = Self::build_config_file_path(config_file_path);
if !path.exists() {
@@ -133,6 +139,12 @@ impl Config {
Self::install_themes(path.clone());
}
+ CONFIG_FILE_PATH.get_or_init(|| path.clone());
+
+ Self::load(path)
+ }
+
+ pub fn load(path: PathBuf) -> Result {
let file = std::fs::File::open(path.clone());
match serde_yaml::from_reader(file.unwrap()) {
@@ -148,7 +160,7 @@ impl Config {
self.styles = ThemeStyles::from(&actual_theme);
let date_format = self.date_format.clone();
- self.date_formater = Box::from(move |s: i64| {
+ self.date_formater = Arc::from(move |s: i64| {
DateTime::from_timestamp(s, 0)
.unwrap()
.with_timezone(&Local::now().timezone())
@@ -394,6 +406,88 @@ impl Config {
std::fs::write(&theme_path, content)
.unwrap_or_else(|_| panic!("Failed create the theme file {:?}", theme_path));
}
+
+ // Save the configuration by applying a patch to the existing config file, to preserve comments and formatting as much as possible
+ pub(crate) fn save(&self) -> Result<(), String> {
+ info!("Saving configuration to {:?}", CONFIG_FILE_PATH.get());
+
+ let config_path = CONFIG_FILE_PATH
+ .get()
+ .ok_or_else(|| String::from("Config file path not initialized"))?
+ .clone();
+ let config_source = fs::read_to_string(&config_path)
+ .map_err(|e| format!("Failed to read config file {:?}: {}", config_path, e))?;
+ let document = yamlpath::Document::new(config_source.clone())
+ .map_err(|e| format!("Failed to parse config file {:?}: {}", config_path, e))?;
+
+ let config_from_file: Config = serde_yaml::from_str(&config_source)
+ .map_err(|e| format!("Failed to parse config file {:?}: {}", config_path, e))?;
+
+ let mut patches: Vec = Vec::new();
+ let mut add_or_replace = |key: &str, value: Value| {
+ let key_owned = key.to_string();
+ let key_route = route!(key_owned.clone());
+ if document.query_exists(&key_route) {
+ patches.push(Patch {
+ route: key_route,
+ operation: Op::Replace(value),
+ });
+ } else {
+ patches.push(Patch {
+ route: route!(),
+ operation: Op::Add {
+ key: key_owned,
+ value,
+ },
+ });
+ }
+ };
+
+ if config_from_file.smart_suggestions_active != self.smart_suggestions_active {
+ add_or_replace(
+ "smart_suggestions_active",
+ Value::Bool(self.smart_suggestions_active),
+ );
+ }
+
+ if config_from_file.smart_suggestions_count != self.smart_suggestions_count {
+ add_or_replace(
+ "smart_suggestions_count",
+ Value::Number(serde_yaml::Number::from(
+ self.smart_suggestions_count as u64,
+ )),
+ );
+ }
+
+ if config_from_file.smart_suggestions_depth != self.smart_suggestions_depth {
+ add_or_replace(
+ "smart_suggestions_depth",
+ Value::Number(serde_yaml::Number::from(
+ self.smart_suggestions_depth as u64,
+ )),
+ );
+ }
+
+ if config_from_file.path_search_include_shortcuts != self.path_search_include_shortcuts {
+ add_or_replace(
+ "path_search_include_shortcuts",
+ Value::Bool(self.path_search_include_shortcuts),
+ );
+ }
+
+ if patches.is_empty() {
+ return Ok(());
+ }
+
+ let updated_document = apply_yaml_patches(&document, &patches)
+ .map_err(|e| format!("Failed to apply config patch {:?}: {}", config_path, e))?;
+ let updated_source = updated_document.source().to_string();
+
+ fs::write(&config_path, updated_source)
+ .map_err(|e| format!("Failed to write config file {:?}: {}", config_path, e))?;
+
+ Ok(())
+ }
}
impl Default for Config {
@@ -410,7 +504,7 @@ impl Default for Config {
smart_suggestions_depth: DEFAULT_SMART_SUGGESTIONS_DEPTH(),
smart_suggestions_count: DEFAULT_SMART_SUGGESTIONS_COUNT(),
themes_directory_path: Default::default(),
- date_formater: Box::new(|date| date.to_string()),
+ date_formater: Arc::new(|date| date.to_string()),
db_path: Default::default(),
log_config_path: Default::default(),
path_search_include_shortcuts: true,
@@ -439,7 +533,7 @@ impl Clone for Config {
path_search_include_shortcuts: self.path_search_include_shortcuts,
date_format: self.date_format.clone(),
// Provide a new default closure for date_formater
- date_formater: Box::new(|date| date.to_string()),
+ date_formater: Arc::new(|date| date.to_string()),
}
}
}
diff --git a/src/config_button.rs b/src/config_button.rs
new file mode 100644
index 0000000..579df9f
--- /dev/null
+++ b/src/config_button.rs
@@ -0,0 +1,67 @@
+use std::{
+ rc::Rc,
+ sync::{Arc, Mutex},
+};
+
+use ratatui::{
+ layout::{Alignment, Position, Rect},
+ prelude::Style,
+ widgets::Paragraph,
+};
+
+use crate::{
+ config::Config,
+ config_view::ConfigView,
+ tui::{ManagerAction, View, ViewBuilder, ViewManager},
+};
+
+pub struct ConfigButton {
+ vm: Rc,
+ config: Arc>,
+}
+
+impl ConfigButton {
+ pub fn builder(vm: Rc, config: Arc>) -> ViewBuilder {
+ ViewBuilder::from(Box::new(ConfigButton { config, vm }))
+ }
+}
+
+impl View for ConfigButton {
+ fn draw(&mut self, frame: &mut ratatui::Frame, area: Rect, _active: bool) {
+ let config_lock = self.config.lock().unwrap();
+ // Fill the frame with the background color if defined
+ if let Some(bg_color) = &config_lock.styles.background_color {
+ // let area = frame.area();
+ let background = Paragraph::new("").style(Style::default().bg(*bg_color));
+ frame.render_widget(background, area);
+ }
+
+ let pa = Paragraph::new("F12: Config")
+ .style(
+ Style::default()
+ .bg(config_lock.styles.header_bg_color.unwrap())
+ .fg(config_lock.styles.header_fg_color.unwrap()),
+ )
+ .alignment(Alignment::Center);
+ frame.render_widget(pa, area);
+ }
+
+ fn handle_mouse_event(
+ &mut self,
+ area: Rect,
+ mouse_event: crossterm::event::MouseEvent,
+ ) -> crate::tui::ManagerAction {
+ let mouse_position = Position::new(mouse_event.column, mouse_event.row);
+ if mouse_event.kind
+ == crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left)
+ && area.contains(mouse_position)
+ {
+ self.vm.show_modal_generic(
+ ConfigView::builder(self.vm.clone(), self.config.clone()),
+ None,
+ );
+ return ManagerAction::new(true);
+ }
+ ManagerAction::new(false)
+ }
+}
diff --git a/src/config_view.rs b/src/config_view.rs
new file mode 100644
index 0000000..0654a87
--- /dev/null
+++ b/src/config_view.rs
@@ -0,0 +1,422 @@
+use std::{
+ rc::Rc,
+ sync::{Arc, Mutex},
+};
+
+use crossterm::event::{KeyCode, KeyEvent};
+use log::{error, info};
+use ratatui::{
+ layout::{Constraint, Direction, Layout, Rect},
+ style::{Color, Modifier, Style},
+ widgets::{Block, Borders, Clear, Paragraph},
+};
+use tokio::sync::broadcast::Sender;
+use tui_textarea::{Input, TextArea};
+
+use crate::{
+ config::Config,
+ tui::{
+ EventCaptured, GenericEvent, ManagerAction, View, ViewBuilder, ViewManager,
+ event::ApplicationEvent,
+ },
+};
+
+#[derive(Copy, Clone, PartialEq)]
+enum ConfigField {
+ SmartSuggestionActiveCheckbox,
+ PathSearchCheckbox,
+ SmartSuggestionCountField,
+ YesButton,
+ CancelButton,
+}
+
+pub struct ConfigView {
+ tx: Sender,
+ config: Arc>,
+ smart_suggestions_field: ConfigField,
+ smart_suggestions_active: bool,
+ path_search_include_shortcuts: bool,
+ count_textarea: Option