1use crate::keys::action_shortcuts::ActionShortcuts;
2use crate::keys::key_strike::KeyStrike;
3use crate::settings::themes::Theme;
4use crate::settings::workspace_config::WorkspaceConfig;
5use std::io::Read;
6use std::path::PathBuf;
7use std::sync::{Arc, RwLock};
8
9use std::fs::{self, File};
10
11#[derive(Debug, thiserror::Error)]
15pub enum SettingsError {
16 #[error(transparent)]
17 Io(#[from] std::io::Error),
18 #[error("cannot serialize settings: {0}")]
19 Serialize(#[from] toml::ser::Error),
20 #[error("corrupt theme file: {0}")]
21 CorruptTheme(toml::de::Error),
22 #[error("config migration failed: {0}")]
23 Migration(String),
24 #[error(transparent)]
25 System(#[from] kimun_core::system::SystemError),
26}
27
28pub type SharedSettings = Arc<RwLock<AppSettings>>;
30use kimun_core::IndexFile;
31
32use self::history::HistoryFile;
33use kimun_core::nfs::{VaultPath, pinned_notes};
34use kimun_core::system::{self, SystemPath};
35
36use crate::keys::KeyBindings;
37pub mod config_migration;
38pub mod history;
39pub mod icons;
40pub mod themes;
41pub mod workspace_config;
42
43#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
48#[serde(rename_all = "lowercase")]
49pub enum SortFieldSetting {
50 Name,
51 Title,
52}
53
54#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
55#[serde(rename_all = "lowercase")]
56pub enum SortOrderSetting {
57 Ascending,
58 Descending,
59}
60
61#[derive(Clone, Copy, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
62#[serde(rename_all = "lowercase")]
63pub enum EditorBackendSetting {
64 #[default]
71 #[serde(alias = "textarea")]
72 Plain,
73 Nvim,
74 Vim,
75}
76
77#[derive(Clone, Copy, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
80#[serde(rename_all = "lowercase")]
81pub enum CtrlHSetting {
82 #[default]
86 Auto,
87 Backspace,
91 Chord,
94}
95
96pub fn config_dir() -> Result<PathBuf, system::SystemError> {
102 system::ensure_app_dir().map(SystemPath::into_path_buf)
103}
104
105const BASE_CONFIG_FILE: &str = "config.toml";
106const THEMES_DIR: &str = "themes";
107
108const CONFIG_HEADER: &str = "\
109# ─── Kimün configuration ────────────────────────────────────────────────────
110#
111# KEY BINDINGS
112# ────────────
113# Supported combinations:
114# - ctrl and/or alt (with optional shift) + a letter (a-z)
115# - bare F-key (F1–F12, no modifier required)
116# Any combo that does not follow these rules is silently ignored when loaded.
117#
118# Format per action:
119# ActionName = [\"<modifiers> & <letter>\", ...]
120#
121# Available modifiers (combine with +): ctrl alt shift
122#
123# Examples:
124# Quit = [\"ctrl&Q\"] # Ctrl+Q
125# SearchNotes = [\"ctrl&K\"] # Ctrl+K
126# OpenNote = [\"ctrl&O\"] # Ctrl+O (fuzzy file finder)
127# OpenSettings = [\"F4\", \"ctrl&,\"] # F4 (Ctrl+, alias)
128# NewJournal = [\"ctrl&J\"] # Ctrl+J
129# FileOperations = [\"F2\"] # F2 (open file-ops menu: delete/rename/move)
130# Leader = [\"ctrl&G\"] # Ctrl+G (leader gateway: Ctrl+G f f, ...)
131# OpenCommandPalette = [\"ctrl&P\"] # Ctrl+P (every leader command, fuzzy)
132#
133# OTHER SETTINGS
134# ──────────────
135# theme = \"Gruvbox Dark\" # or any built-in / custom theme name
136# leader_timeout_ms = 400 # hesitation before the which-key menu
137# ctrl_h = \"auto\" # auto | backspace | chord
138# What a bare 0x08 byte means. Backspace and Ctrl-H are the same byte
139# on terminals without the kitty keyboard protocol, so only one of the
140# two can work there. \"auto\" keeps the Ctrl-H chord unless the tty's
141# erase character is 0x08; set \"backspace\" if your Backspace key moves
142# focus instead of deleting, \"chord\" to always keep the chord.
143#
144# LEADER TREE OVERRIDES
145# ─────────────────────
146# Remap, add, or remove leader sequences ([leader.bind]) and rename group
147# captions ([leader.labels]). Keys are the sequence AFTER the gateway;
148# bind values are action ids (see the cheatsheet) or \"none\" to unbind.
149# [leader.bind]
150# \"o f\" = \"find.files\" # remap: leader o f now opens the file picker
151# \"x\" = \"note.daily\" # add: leader x opens today's journal
152# \"g p\" = \"none\" # remove the git-sync stub binding
153# [leader.labels]
154# \"f\" = \"+search\" # rename the +find group caption
155#
156# ─────────────────────────────────────────────────────────────────────────────
157";
158
159#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, PartialEq)]
160pub struct AppSettings {
161 #[serde(default)]
163 pub config_version: u32,
164 #[serde(flatten, skip_serializing_if = "Option::is_none")]
165 pub workspace_config: Option<WorkspaceConfig>,
166
167 #[serde(default)]
169 pub theme: String,
170 #[serde(default = "default_cache_dir")]
172 pub cache_dir: PathBuf,
173 #[serde(skip, default = "default_cache_dir_resolved")]
179 cache_dir_resolved: SystemPath,
180
181 #[serde(default = "default_history_dir")]
183 pub history_dir: PathBuf,
184 #[serde(skip, default = "default_history_dir_resolved")]
186 history_dir_resolved: SystemPath,
187 #[serde(skip, default = "yes")]
188 needs_indexing: bool,
189 #[serde(default = "default_keybindings")]
190 pub key_bindings: KeyBindings,
191 #[serde(default = "default_autosave_interval")]
192 pub autosave_interval_secs: u64,
193 #[serde(default = "default_leader_timeout_ms")]
196 pub leader_timeout_ms: u64,
197 #[serde(default)]
201 pub leader: LeaderConfig,
202 #[serde(default = "default_use_nerd_fonts")]
203 pub use_nerd_fonts: bool,
204 #[serde(default)]
205 pub editor_backend: EditorBackendSetting,
206 #[serde(skip_serializing_if = "Option::is_none")]
207 pub nvim_path: Option<std::path::PathBuf>,
208 #[serde(default = "default_sort_field")]
209 pub default_sort_field: SortFieldSetting,
210 #[serde(default = "default_sort_order")]
211 pub default_sort_order: SortOrderSetting,
212 #[serde(default = "default_journal_sort_field")]
213 pub journal_sort_field: SortFieldSetting,
214 #[serde(default = "default_journal_sort_order")]
215 pub journal_sort_order: SortOrderSetting,
216 #[serde(default)]
217 pub group_directories: bool,
218 #[serde(default)]
222 pub ctrl_h: CtrlHSetting,
223 #[serde(skip)]
226 pub config_file: Option<PathBuf>,
227}
228
229fn default_keybindings() -> KeyBindings {
230 let mut kb = KeyBindings::empty();
231 kb.batch_add()
247 .with_ctrl()
248 .add(KeyStrike::KeyK, ActionShortcuts::SearchNotes)
249 .add(KeyStrike::KeyO, ActionShortcuts::OpenNote);
250
251 kb.batch_add()
255 .with_ctrl()
256 .add(KeyStrike::KeyP, ActionShortcuts::OpenCommandPalette)
259 .add(KeyStrike::KeyQ, ActionShortcuts::Quit)
260 .add(KeyStrike::KeyJ, ActionShortcuts::NewJournal)
261 .add(KeyStrike::KeyT, ActionShortcuts::ToggleSidebar)
268 .add(KeyStrike::KeyR, ActionShortcuts::OpenSortDialog)
269 .add(KeyStrike::KeyG, ActionShortcuts::Leader)
272 .add(KeyStrike::KeyN, ActionShortcuts::FollowLink)
275 .add(KeyStrike::KeyH, ActionShortcuts::FocusSidebar)
276 .add(KeyStrike::KeyL, ActionShortcuts::FocusEditor)
277 .add(KeyStrike::KeyW, ActionShortcuts::QuickNote)
278 .add(KeyStrike::KeyE, ActionShortcuts::OpenFileBrowser)
282 .add(KeyStrike::KeyF, ActionShortcuts::FindInBuffer)
283 .add(
288 crate::keys::default_yank_combo().key,
289 ActionShortcuts::YankRow,
290 );
291
292 kb.batch_add()
298 .add(KeyStrike::F4, ActionShortcuts::OpenPreferences);
299 kb.batch_add()
300 .with_ctrl()
301 .add(KeyStrike::Comma, ActionShortcuts::OpenPreferences);
302
303 kb.batch_add()
305 .add(KeyStrike::F2, ActionShortcuts::FileOperations);
306
307 kb.batch_add()
308 .add(KeyStrike::F3, ActionShortcuts::OpenSavedSearches);
309
310 kb.batch_add().add(KeyStrike::F6, ActionShortcuts::OpenAsk);
312
313 kb.batch_add()
315 .add(KeyStrike::F5, ActionShortcuts::SwitchWorkspace);
316
317 kb.batch_add()
323 .with_ctrl()
324 .add(KeyStrike::KeyD, ActionShortcuts::SaveCurrentQuery);
325
326 kb
327}
328
329pub fn delete_artifacts(index: &IndexFile, history: &HistoryFile) -> Vec<String> {
343 let mut leftovers = Vec::new();
344 let stuck = index.remove();
348 if stuck.is_empty() {
349 tracing::info!("removed index {}", index);
350 }
351 for (path, e) in stuck {
352 tracing::warn!("failed to remove {}: {}", path, e);
353 leftovers.push(format!(" {path}\n {e}"));
354 }
355 if let Err(e) = history.remove() {
356 tracing::warn!("failed to remove history {}: {}", history, e);
357 leftovers.push(format!(" {history}\n {e}"));
358 } else {
359 tracing::info!("removed history {}", history);
360 }
361 leftovers
362}
363
364fn yes() -> bool {
365 true
366}
367
368fn default_autosave_interval() -> u64 {
369 5
370}
371
372fn default_leader_timeout_ms() -> u64 {
373 400
374}
375
376#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
378pub struct LeaderConfig {
379 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
382 pub bind: std::collections::BTreeMap<String, String>,
383 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
386 pub labels: std::collections::BTreeMap<String, String>,
387}
388
389impl AppSettings {
390 pub fn default_workspace_suggestion() -> Option<PathBuf> {
393 system::home()
394 .ok()
395 .map(|h| h.join("kimun-notes").into_path_buf())
396 }
397
398 pub fn leader_tree(&self) -> crate::keys::leader::LeaderNode {
402 let tree = crate::keys::leader::apply_overrides(
403 crate::keys::leader::leader_tree(),
404 self.leader
405 .bind
406 .iter()
407 .map(|(k, v)| (k.as_str(), v.as_str())),
408 );
409 crate::keys::leader::apply_labels(
410 tree,
411 self.leader
412 .labels
413 .iter()
414 .map(|(k, v)| (k.as_str(), v.as_str())),
415 )
416 }
417}
418
419fn default_cache_dir() -> PathBuf {
420 PathBuf::from(".")
421}
422
423fn default_history_dir() -> PathBuf {
424 PathBuf::from("history")
425}
426
427fn default_settings_base_dir() -> SystemPath {
439 system::app_dir().unwrap_or_else(|_| {
440 let fallback = std::env::temp_dir().join("kimun");
441 SystemPath::try_absolute(&fallback).unwrap_or_else(|_| system::log_dir())
442 })
443}
444
445fn default_cache_dir_resolved() -> SystemPath {
449 SystemPath::resolve(default_cache_dir(), &default_settings_base_dir())
450}
451
452fn default_history_dir_resolved() -> SystemPath {
454 SystemPath::resolve(default_history_dir(), &default_settings_base_dir())
455}
456
457fn default_use_nerd_fonts() -> bool {
458 false
459}
460
461fn default_sort_field() -> SortFieldSetting {
462 SortFieldSetting::Name
463}
464
465fn default_sort_order() -> SortOrderSetting {
466 SortOrderSetting::Ascending
467}
468
469fn default_journal_sort_field() -> SortFieldSetting {
470 SortFieldSetting::Name
471}
472
473fn default_journal_sort_order() -> SortOrderSetting {
474 SortOrderSetting::Descending
475}
476
477impl Default for AppSettings {
478 fn default() -> Self {
479 Self {
480 config_version: 0,
481 workspace_config: None,
482 theme: Default::default(),
483 cache_dir: default_cache_dir(),
484 cache_dir_resolved: default_cache_dir_resolved(),
485 history_dir: default_history_dir(),
486 history_dir_resolved: default_history_dir_resolved(),
487 needs_indexing: true,
488 key_bindings: default_keybindings(),
489 autosave_interval_secs: default_autosave_interval(),
490 leader_timeout_ms: default_leader_timeout_ms(),
491 leader: LeaderConfig::default(),
492 use_nerd_fonts: false,
493 editor_backend: EditorBackendSetting::Plain,
494 nvim_path: None,
495 default_sort_field: default_sort_field(),
496 default_sort_order: default_sort_order(),
497 journal_sort_field: default_journal_sort_field(),
498 journal_sort_order: default_journal_sort_order(),
499 group_directories: false,
500 ctrl_h: CtrlHSetting::default(),
501 config_file: None,
502 }
503 }
504}
505
506impl AppSettings {
507 pub fn theme_list(&self) -> Vec<Theme> {
508 let mut list = Theme::builtins();
509 list.append(&mut Self::load_custom_themes());
510 if let Ok(custom_default) = Self::load_default_theme() {
512 list.push(custom_default);
513 }
514 list.sort_by(|a, b| a.name.cmp(&b.name));
515 list
516 }
517
518 fn default_config_file_path() -> Result<PathBuf, SettingsError> {
519 Ok(system::ensure_app_dir()?
520 .join(BASE_CONFIG_FILE)
521 .into_path_buf())
522 }
523
524 fn get_config_file_path(&self) -> Result<PathBuf, SettingsError> {
525 if let Some(ref path) = self.config_file {
526 Ok(path.clone())
527 } else {
528 Self::default_config_file_path()
529 }
530 }
531
532 fn get_themes_path() -> Result<PathBuf, SettingsError> {
533 Ok(system::ensure_app_dir()?.join(THEMES_DIR).into_path_buf())
534 }
535
536 fn load_theme_from_path(path: &std::path::Path) -> Result<Theme, SettingsError> {
537 let theme_string = fs::read_to_string(path)?;
538 match toml::from_str::<Theme>(&theme_string) {
539 Ok(theme) => Ok(theme),
540 Err(e) => {
541 tracing::warn!("Skipping unparsable theme file {:?}: {}", path, e);
544 Err(SettingsError::CorruptTheme(e))
545 }
546 }
547 }
548
549 fn load_default_theme() -> Result<Theme, SettingsError> {
550 let theme_path = AppSettings::get_themes_path()?.join("default.toml");
551 Self::load_theme_from_path(&theme_path)
552 }
553
554 fn load_custom_themes() -> Vec<Theme> {
555 let mut themes = Vec::new();
556
557 let themes_path = match Self::get_themes_path() {
559 Ok(path) => path,
560 Err(_) => return themes,
561 };
562
563 let entries =
565 match SystemPath::try_absolute(&themes_path).and_then(|dir| system::read_dir(&dir)) {
566 Ok(entries) => entries,
567 Err(_) => return themes,
568 };
569
570 for entry in entries {
572 let path = entry.into_path_buf();
573
574 if !path.is_file() {
576 continue;
577 }
578
579 if path.extension().and_then(|s| s.to_str()) != Some("toml") {
581 continue;
582 }
583
584 if path.file_name().and_then(|s| s.to_str()) == Some("default.toml") {
586 continue;
587 }
588
589 match fs::read_to_string(&path)
591 .and_then(|s| toml::from_str::<Theme>(&s).map_err(std::io::Error::other))
592 {
593 Ok(theme) => themes.push(theme),
594 Err(e) => tracing::warn!("Skipping theme file {:?}: {}", path, e),
595 }
596 }
597
598 themes
599 }
600
601 pub fn update_check(&self) -> bool {
605 self.workspace_config
606 .as_ref()
607 .map(|wc| wc.global.update_check)
608 .unwrap_or(true)
609 }
610
611 pub fn mouse(&self) -> bool {
614 self.workspace_config
615 .as_ref()
616 .map(|wc| wc.global.mouse)
617 .unwrap_or(true)
618 }
619
620 pub fn save_to_disk(&self) -> Result<(), SettingsError> {
621 tracing::debug!("Saving settings to disk");
622 let settings_file_path = self.get_config_file_path()?;
623 let body = format!("{CONFIG_HEADER}{}", toml::to_string(&self)?);
627 system::replace_atomically(&settings_file_path, body.as_bytes())?;
628 Ok(())
629 }
630
631 pub fn load_from_disk() -> Result<Self, SettingsError> {
632 let settings_file_path = Self::default_config_file_path()?;
633
634 if !settings_file_path.exists() {
635 let default_settings = Self::defaults_for_config_file(settings_file_path);
636 default_settings.save_to_disk()?;
637 Ok(default_settings)
638 } else {
639 let mut settings_file = File::open(&settings_file_path)?;
640
641 let mut toml = String::new();
642 settings_file.read_to_string(&mut toml)?;
643
644 match toml::from_str::<AppSettings>(toml.as_ref()) {
645 Ok(mut setting) => {
646 setting.config_file = Some(settings_file_path.clone());
647 setting.resolve_paths(&Self::config_base_dir(&settings_file_path));
650 if config_migration::ConfigMigration::run(&mut setting)? {
651 setting.save_to_disk()?;
652 }
653 setting.merge_missing_default_bindings();
654 Ok(setting)
655 }
656 Err(e) => {
657 tracing::warn!(
658 "Config file at {:?} could not be parsed ({}). \
659 Renaming to .corrupt and starting with defaults.",
660 settings_file_path,
661 e
662 );
663 let corrupt_path = settings_file_path.with_extension("toml.corrupt");
664 let _ = system::move_file(&settings_file_path, &corrupt_path);
665 let defaults = Self::defaults_for_config_file(settings_file_path);
666 defaults.save_to_disk()?;
667 Ok(defaults)
668 }
669 }
670 }
671 }
672
673 pub fn load_from_file(path: PathBuf) -> Result<Self, SettingsError> {
674 if let Some(parent) = path.parent()
679 && !parent.as_os_str().is_empty()
680 {
681 system::create_dir(parent)?;
682 }
683 if !path.exists() {
684 let default_settings = Self::defaults_for_config_file(path);
685 default_settings.save_to_disk()?;
686 return Ok(default_settings);
687 }
688 let mut toml_str = String::new();
689 File::open(&path)?.read_to_string(&mut toml_str)?;
690 match toml::from_str::<AppSettings>(&toml_str) {
691 Ok(mut setting) => {
692 setting.config_file = Some(path.clone());
693
694 setting.resolve_paths(&Self::config_base_dir(&path));
697
698 if config_migration::ConfigMigration::run(&mut setting)? {
700 setting.save_to_disk()?;
701 }
702
703 setting.merge_missing_default_bindings();
704 Ok(setting)
705 }
706 Err(e) => {
707 tracing::warn!(
708 "Config file at {:?} could not be parsed ({}). \
709 Renaming to .corrupt and starting with defaults.",
710 path,
711 e
712 );
713 let corrupt_path = path.with_extension("toml.corrupt");
714 let _ = system::move_file(&path, &corrupt_path);
715 let defaults = Self::defaults_for_config_file(path);
716 defaults.save_to_disk()?;
717 Ok(defaults)
718 }
719 }
720 }
721
722 fn merge_missing_default_bindings(&mut self) {
728 let defaults = default_keybindings().to_hashmap();
729 let mut current = self.key_bindings.to_hashmap();
730 let mut bound: std::collections::HashSet<_> = current.values().flatten().cloned().collect();
731 for (action, combos) in defaults {
732 match current.entry(action) {
733 std::collections::hash_map::Entry::Vacant(e) => {
734 let free: Vec<_> = combos.into_iter().filter(|c| !bound.contains(c)).collect();
738 if !free.is_empty() {
739 bound.extend(free.iter().copied());
740 e.insert(free);
741 }
742 }
743 std::collections::hash_map::Entry::Occupied(mut e) => {
744 for combo in combos {
745 if !bound.contains(&combo) && !e.get().contains(&combo) {
746 bound.insert(combo);
747 e.get_mut().push(combo);
748 }
749 }
750 }
751 }
752 }
753 self.key_bindings = KeyBindings::from_hashmap(current);
754 }
755
756 pub fn set_workspace_path(&mut self, name: &str, workspace_path: PathBuf) {
761 let Some(entry) = self
762 .workspace_config
763 .as_mut()
764 .and_then(|wc| wc.workspaces.get_mut(name))
765 else {
766 return;
767 };
768 if *entry.effective_path() != workspace_path {
769 self.needs_indexing = true;
770 }
771 entry.path = workspace_path;
772 entry.resolved_path = None;
773 }
774
775 pub fn clear_workspace(&mut self) {
788 if let Some(wc) = &mut self.workspace_config {
789 let key = wc.global.current_workspace.clone();
790 if !key.is_empty() {
791 wc.workspaces.remove(&key);
792 }
793 wc.global.current_workspace = String::new();
794 }
795 }
796
797 pub fn resolve_workspace_path(&self) -> Option<SystemPath> {
800 let raw = self
801 .workspace_config
802 .as_ref()
803 .and_then(|wc| wc.get_current_workspace())
804 .map(|entry| entry.effective_path().clone())?;
805 match SystemPath::try_absolute(&raw) {
810 Ok(path) => Some(path),
811 Err(e) => {
812 tracing::warn!("ignoring unusable workspace path {raw:?}: {e}");
813 None
814 }
815 }
816 }
817
818 fn resolve_paths(&mut self, base: &SystemPath) {
822 if let Some(ref mut wc) = self.workspace_config {
824 for entry in wc.workspaces.values_mut() {
825 let resolved = SystemPath::resolve(&entry.path, base).into_path_buf();
826 if resolved != entry.path {
827 entry.resolved_path = Some(resolved);
828 }
829 }
830 }
831 self.cache_dir_resolved = SystemPath::resolve(&self.cache_dir, base);
832 self.history_dir_resolved = SystemPath::resolve(&self.history_dir, base);
833 }
834
835 fn defaults_for_config_file(path: PathBuf) -> Self {
846 let base = Self::config_base_dir(&path);
847 let mut settings = Self {
848 config_file: Some(path),
849 ..Self::default()
850 };
851 settings.resolve_paths(&base);
852 settings
853 }
854
855 fn config_base_dir(config_file: &std::path::Path) -> SystemPath {
873 let dir = config_file
874 .parent()
875 .filter(|p| !p.as_os_str().is_empty())
876 .unwrap_or(std::path::Path::new("."));
877 SystemPath::canonical(dir)
881 .or_else(|_| {
882 let cwd = std::env::current_dir().unwrap_or_default();
883 SystemPath::try_absolute(cwd.join(dir))
884 })
885 .unwrap_or_else(|_| default_settings_base_dir())
886 }
887
888 pub fn set_theme(&mut self, theme: String) {
889 self.theme = theme;
890 }
891
892 pub fn report_indexed(&mut self) {
893 self.needs_indexing = false;
894 }
895
896 pub fn needs_indexing(&self) -> bool {
897 self.needs_indexing
898 }
899
900 pub fn add_path_history(&self, note_path: &VaultPath) {
901 if !note_path.is_note() {
902 return;
903 }
904 let Some(history) = self.current_history() else {
905 return;
906 };
907 if let Err(e) = history.push(note_path) {
908 tracing::warn!("failed to write history {history}: {e}");
909 }
910 }
911
912 pub fn follow_rename_in_history(&self, from: &VaultPath, to: &VaultPath) {
916 self.edit_path_history(|paths| {
917 if from.is_note() {
918 pinned_notes::rewrite_note_rename(paths, from, to)
919 } else {
920 pinned_notes::rewrite_directory_rename(paths, from, to)
921 }
922 });
923 }
924
925 pub fn follow_delete_in_history(&self, path: &VaultPath) {
928 self.edit_path_history(|paths| {
929 if path.is_note() {
930 pinned_notes::remove(paths, path)
931 } else {
932 pinned_notes::remove_under_directory(paths, path)
933 }
934 });
935 }
936
937 pub fn edit_path_history(&self, f: impl FnOnce(&mut Vec<VaultPath>) -> bool) {
941 let Some(history) = self.current_history() else {
942 return;
943 };
944 if let Err(e) = history.edit(f) {
945 tracing::warn!("failed to write history {history}: {e}");
946 }
947 }
948
949 fn current_history(&self) -> Option<HistoryFile> {
951 self.current_workspace_name()
952 .map(|name| self.history_for(&name))
953 }
954
955 pub fn current_workspace_name(&self) -> Option<String> {
956 self.workspace_config
957 .as_ref()
958 .map(|wc| wc.global.current_workspace.clone())
959 .filter(|s| !s.is_empty())
960 }
961
962 pub fn cache_dir_resolved(&self) -> &SystemPath {
964 &self.cache_dir_resolved
965 }
966
967 pub fn history_dir_resolved(&self) -> &SystemPath {
969 &self.history_dir_resolved
970 }
971
972 fn file_key_for(&self, workspace_name: &str) -> String {
981 self.workspace_config
982 .as_ref()
983 .and_then(|wc| wc.get_workspace(workspace_name))
984 .map(|entry| entry.file_key_or(workspace_name))
985 .unwrap_or_else(|| workspace_name.to_string())
986 }
987
988 pub fn index_for(&self, workspace_name: &str) -> IndexFile {
995 IndexFile::in_dir(&self.cache_dir_resolved, &self.file_key_for(workspace_name))
996 }
997
998 pub fn history_for(&self, workspace_name: &str) -> HistoryFile {
1002 HistoryFile::in_dir(
1003 &self.history_dir_resolved,
1004 &self.file_key_for(workspace_name),
1005 )
1006 }
1007
1008 pub fn workspace_artifacts(&self, workspace_name: &str) -> (IndexFile, HistoryFile) {
1018 (
1019 self.index_for(workspace_name),
1020 self.history_for(workspace_name),
1021 )
1022 }
1023
1024 pub fn current_last_paths(&self) -> Vec<VaultPath> {
1026 self.current_history()
1027 .map(|history| history.load())
1028 .unwrap_or_default()
1029 }
1030
1031 #[cfg(test)]
1037 pub(crate) fn for_test_workspace(
1038 name: &str,
1039 workspace: &SystemPath,
1040 scratch_dir: SystemPath,
1041 ) -> Self {
1042 let mut wc = WorkspaceConfig::new_empty();
1043 wc.add_workspace(name.to_string(), workspace.clone().into_path_buf())
1044 .unwrap();
1045 Self {
1046 workspace_config: Some(wc),
1047 config_file: Some(scratch_dir.join("config.toml").into_path_buf()),
1048 history_dir_resolved: scratch_dir,
1049 ..Self::default()
1050 }
1051 }
1052
1053 #[cfg(test)]
1055 pub(crate) fn seed_test_history(&self, paths: &[&str]) {
1056 let paths: Vec<VaultPath> = paths.iter().map(|p| VaultPath::new(*p)).collect();
1057 self.current_history().unwrap().write(&paths).unwrap();
1058 }
1059
1060 pub fn icons(&self) -> icons::Icons {
1062 icons::Icons::new(self.use_nerd_fonts)
1063 }
1064
1065 pub fn yank_combos(&self) -> Vec<crate::keys::key_combo::KeyCombo> {
1069 self.key_bindings.combos_for(&ActionShortcuts::YankRow)
1070 }
1071
1072 pub fn effective_theme_name(&self) -> String {
1076 if self.theme.is_empty() {
1077 Theme::default().name
1078 } else {
1079 self.theme.clone()
1080 }
1081 }
1082
1083 pub fn get_theme(&self) -> Theme {
1089 let theme = if self.theme.is_empty() {
1090 Theme::default()
1091 } else {
1092 self.theme_list()
1093 .into_iter()
1094 .find(|t| t.name == self.theme)
1095 .unwrap_or_default()
1096 };
1097 theme.adapt_to_terminal()
1098 }
1099}
1100
1101#[cfg(test)]
1103mod test_paths {
1104 use std::path::PathBuf;
1105
1106 pub(super) fn absolute(unix_style: &str) -> PathBuf {
1118 let trimmed = unix_style.trim_start_matches('/');
1119 if cfg!(windows) {
1120 PathBuf::from(format!("C:\\{}", trimmed.replace('/', "\\")))
1121 } else {
1122 PathBuf::from(format!("/{trimmed}"))
1123 }
1124 }
1125
1126 pub(super) fn absolute_toml(unix_style: &str) -> String {
1129 absolute(unix_style).to_string_lossy().replace('\\', "\\\\")
1130 }
1131}
1132
1133#[cfg(test)]
1134#[allow(clippy::field_reassign_with_default)]
1135mod tests {
1136 use super::test_paths::absolute;
1137 use super::*;
1138
1139 #[test]
1140 fn default_workspace_suggestion_is_under_home() {
1141 let suggestion = AppSettings::default_workspace_suggestion();
1142 if let Some(p) = suggestion {
1143 assert!(p.ends_with("kimun-notes"));
1144 assert!(p.is_absolute());
1145 }
1146 }
1148
1149 #[test]
1150 fn load_theme_from_nonexistent_path_returns_err_without_creating_file() {
1151 let path = std::env::temp_dir().join("kimun_tdd_test_theme_absent.toml");
1154 let _ = std::fs::remove_file(&path); let result = AppSettings::load_theme_from_path(&path);
1157
1158 assert!(result.is_err(), "should return Err when file is absent");
1159 assert!(!path.exists(), "must not create the file as a side effect");
1160 }
1161
1162 #[test]
1163 fn load_theme_from_corrupt_path_returns_err_without_recreating_file() {
1164 let path = std::env::temp_dir().join("kimun_tdd_test_theme_corrupt.toml");
1166 std::fs::write(&path, b"not valid toml {{{{").unwrap();
1167
1168 let result = AppSettings::load_theme_from_path(&path);
1169
1170 assert!(result.is_err(), "should return Err for corrupt TOML");
1171 assert!(path.exists(), "corrupt theme file must not be deleted");
1174 std::fs::remove_file(&path).ok();
1175 }
1176
1177 #[test]
1178 fn default_keybindings_quit_matches_canonical_combo() {
1179 let kb = default_keybindings();
1180 let combo = crate::keys::default_quit_combo();
1181 assert_eq!(
1182 kb.get_action(&combo),
1183 Some(ActionShortcuts::Quit),
1184 "default_keybindings() must bind default_quit_combo() to Quit so the \
1185 deserialize safety net can recover an unreachable app"
1186 );
1187 }
1188
1189 #[test]
1190 fn autosave_interval_defaults_to_five() {
1191 let settings = AppSettings::default();
1192 assert_eq!(settings.autosave_interval_secs, 5);
1193 }
1194
1195 #[test]
1196 fn autosave_interval_deserializes_from_toml() {
1197 let toml = "autosave_interval_secs = 30\n";
1198 let settings: AppSettings = toml::from_str(toml).unwrap();
1199 assert_eq!(settings.autosave_interval_secs, 30);
1200 }
1201
1202 #[test]
1203 fn autosave_interval_defaults_when_missing_from_toml() {
1204 let toml = ""; let settings: AppSettings = toml::from_str(toml).unwrap();
1206 assert_eq!(settings.autosave_interval_secs, 5);
1207 }
1208
1209 #[test]
1211 fn f2_file_operations_survives_toml_deserialize() {
1212 use crate::keys::key_combo::{KeyCombo, KeyModifiers};
1213 use crate::keys::key_strike::KeyStrike;
1214
1215 let toml = r#"
1216[key_bindings]
1217FileOperations = ["F2"]
1218"#;
1219 let settings: AppSettings = toml::from_str(toml).unwrap();
1220 let f2 = KeyCombo::new(KeyModifiers::default(), KeyStrike::F2);
1221 let action = settings.key_bindings.get_action(&f2);
1222 assert_eq!(
1223 action,
1224 Some(ActionShortcuts::FileOperations),
1225 "F2 should survive deserialization and map to FileOperations"
1226 );
1227 }
1228
1229 #[test]
1231 fn merge_adds_f2_when_absent() {
1232 use crate::keys::key_combo::{KeyCombo, KeyModifiers};
1233 use crate::keys::key_strike::KeyStrike;
1234
1235 let toml = r#"
1237[key_bindings]
1238Quit = ["ctrl&Q"]
1239"#;
1240 let mut settings: AppSettings = toml::from_str(toml).unwrap();
1241 settings.merge_missing_default_bindings();
1242
1243 let f2 = KeyCombo::new(KeyModifiers::default(), KeyStrike::F2);
1244 let action = settings.key_bindings.get_action(&f2);
1245 assert_eq!(
1246 action,
1247 Some(ActionShortcuts::FileOperations),
1248 "merge_missing_default_bindings should add F2 → FileOperations"
1249 );
1250 }
1251
1252 #[test]
1253 fn clear_workspace_removes_the_current_workspace_entry() {
1254 let mut settings = AppSettings::default();
1255 let mut wc = WorkspaceConfig::new_empty();
1256 wc.add_workspace("vault1".to_string(), absolute("/tmp/vault1"))
1257 .unwrap();
1258 settings.workspace_config = Some(wc);
1259 assert_eq!(
1261 settings
1262 .workspace_config
1263 .as_ref()
1264 .unwrap()
1265 .global
1266 .current_workspace,
1267 "vault1"
1268 );
1269 settings.clear_workspace();
1270 let wc = settings.workspace_config.as_ref().unwrap();
1271 assert!(
1272 wc.workspaces.is_empty(),
1273 "workspace entry should be removed"
1274 );
1275 assert!(
1276 wc.global.current_workspace.is_empty(),
1277 "current_workspace should be empty"
1278 );
1279 }
1280
1281 #[test]
1282 fn clear_workspace_preserves_other_workspaces() {
1283 let mut settings = AppSettings::default();
1284 let mut wc = WorkspaceConfig::new_empty();
1285 wc.add_workspace("vault1".to_string(), absolute("/tmp/vault1"))
1286 .unwrap();
1287 wc.add_workspace("vault2".to_string(), PathBuf::from("/tmp/vault2"))
1288 .unwrap();
1289 wc.global.current_workspace = "vault1".to_string();
1290 settings.workspace_config = Some(wc);
1291 settings.clear_workspace();
1292 let wc = settings.workspace_config.as_ref().unwrap();
1293 assert!(
1294 !wc.workspaces.contains_key("vault1"),
1295 "active workspace should be removed"
1296 );
1297 assert!(
1298 wc.workspaces.contains_key("vault2"),
1299 "other workspaces should be preserved"
1300 );
1301 assert!(
1302 wc.global.current_workspace.is_empty(),
1303 "current_workspace should be empty"
1304 );
1305 }
1306}
1307
1308#[cfg(test)]
1309mod backend_tests {
1310 use super::test_paths::{absolute, absolute_toml};
1311 use super::*;
1312
1313 #[test]
1314 fn a_config_still_saying_textarea_loads() {
1315 #[derive(serde::Deserialize)]
1319 struct Holder {
1320 editor_backend: EditorBackendSetting,
1321 }
1322 let old: Holder = toml::from_str("editor_backend = \"textarea\"").expect("still loads");
1323 assert_eq!(old.editor_backend, EditorBackendSetting::Plain);
1324 }
1325
1326 #[test]
1327 fn the_backend_is_written_back_as_plain() {
1328 let written = toml::to_string(&AppSettings::default()).expect("serialises");
1329 assert!(
1330 written.contains("editor_backend = \"plain\""),
1331 "a saved config should name the value as it is now: {written}"
1332 );
1333 }
1334
1335 #[test]
1336 fn default_backend_is_plain() {
1337 let settings = AppSettings::default();
1338 assert!(matches!(
1339 settings.editor_backend,
1340 EditorBackendSetting::Plain
1341 ));
1342 }
1343
1344 #[test]
1345 fn nvim_backend_round_trips_toml() {
1346 let toml = "editor_backend = \"nvim\"\n";
1347 let parsed: AppSettings = toml::from_str(toml).unwrap();
1348 assert!(matches!(parsed.editor_backend, EditorBackendSetting::Nvim));
1349 }
1350
1351 #[test]
1352 fn editor_backend_vim_roundtrips_through_toml() {
1353 #[derive(serde::Serialize, serde::Deserialize)]
1354 struct W {
1355 editor_backend: EditorBackendSetting,
1356 }
1357 let w = W {
1358 editor_backend: EditorBackendSetting::Vim,
1359 };
1360 let s = toml::to_string(&w).unwrap();
1361 assert!(s.contains("editor_backend = \"vim\""), "serialized: {s}");
1362 let back: W = toml::from_str(&s).unwrap();
1363 assert_eq!(back.editor_backend, EditorBackendSetting::Vim);
1364 }
1365
1366 #[test]
1377 fn every_default_action_keeps_a_chord_a_legacy_terminal_can_send() {
1378 use crate::keys::reachability::{TerminalKeys, reach};
1379
1380 for (action, combos) in default_keybindings().to_hashmap() {
1381 let verdicts: Vec<String> = combos
1382 .iter()
1383 .map(|c| format!("{c}: {}", reach(*c, TerminalKeys::LEGACY)))
1384 .collect();
1385 assert!(
1386 combos
1387 .iter()
1388 .any(|c| reach(*c, TerminalKeys::LEGACY).is_ok()),
1389 "{action} has no chord a legacy terminal can send: {}",
1390 verdicts.join("; ")
1391 );
1392 }
1393 }
1394
1395 #[test]
1401 fn an_unreachable_chord_is_allowed_as_an_alias() {
1402 use crate::keys::key_combo::{KeyCombo, KeyModifiers};
1403 use crate::keys::reachability::{Reach, TerminalKeys, reach};
1404
1405 let kb = default_keybindings();
1406 let combos = kb.combos_for(&ActionShortcuts::OpenPreferences);
1407 let ctrl_comma = KeyCombo::new(KeyModifiers::new().and_ctrl(), KeyStrike::Comma);
1408 assert!(combos.contains(&ctrl_comma), "the alias is still bound");
1409 assert_eq!(
1410 reach(ctrl_comma, TerminalKeys::LEGACY),
1411 Reach::Untransmitted,
1412 "and it is still the unreachable one"
1413 );
1414 assert!(
1415 combos.contains(&KeyCombo::new(KeyModifiers::new(), KeyStrike::F4)),
1416 "F4 is what actually carries Preferences"
1417 );
1418 }
1419
1420 #[test]
1428 fn the_default_table_binds_no_text_action() {
1429 let kb = default_keybindings();
1430 let bound: Vec<String> = kb
1431 .to_hashmap()
1432 .keys()
1433 .filter_map(|a| match a {
1434 ActionShortcuts::Text(t) => Some(format!("{t:?}")),
1435 _ => None,
1436 })
1437 .collect();
1438 assert!(
1439 bound.is_empty(),
1440 "formatting belongs on the leader, but these still hold chords: {bound:?}"
1441 );
1442 }
1443
1444 #[test]
1450 fn ctrl_l_and_ctrl_t_belong_to_focus_and_the_drawer() {
1451 let kb = default_keybindings();
1452 use crate::keys::key_combo::{KeyCombo, KeyModifiers};
1453 let ctrl = |key| KeyCombo::new(KeyModifiers::new().and_ctrl(), key);
1454 assert_eq!(
1455 kb.get_action(&ctrl(KeyStrike::KeyL)),
1456 Some(ActionShortcuts::FocusEditor)
1457 );
1458 assert_eq!(
1459 kb.get_action(&ctrl(KeyStrike::KeyT)),
1460 Some(ActionShortcuts::ToggleSidebar)
1461 );
1462 }
1463
1464 #[test]
1467 fn ctrl_h_parses_and_defaults_to_auto() {
1468 for (written, expected) in [
1469 ("auto", CtrlHSetting::Auto),
1470 ("backspace", CtrlHSetting::Backspace),
1471 ("chord", CtrlHSetting::Chord),
1472 ] {
1473 let s = toml::from_str::<AppSettings>(&format!("ctrl_h = \"{written}\"\n"))
1474 .unwrap_or_else(|e| panic!("ctrl_h = \"{written}\" must parse: {e}"));
1475 assert_eq!(s.ctrl_h, expected);
1476 }
1477 assert_eq!(
1478 toml::from_str::<AppSettings>("theme = \"gruvbox_dark\"\n")
1479 .unwrap()
1480 .ctrl_h,
1481 CtrlHSetting::Auto,
1482 "a config written before the setting existed must not change behaviour"
1483 );
1484 }
1485
1486 #[test]
1492 fn workspace_paths_are_absolute_without_resolve_paths() {
1493 for settings in [
1494 AppSettings::default(),
1495 toml::from_str::<AppSettings>("theme = \"gruvbox_dark\"\n").unwrap(),
1496 ] {
1497 let cache = settings.index_for("w");
1498 let history = settings.history_for("w");
1499 assert!(
1500 cache.path().as_path().is_absolute(),
1501 "cache path not absolute: {cache}"
1502 );
1503 assert!(
1504 history.path().as_path().is_absolute(),
1505 "history path not absolute: {history}"
1506 );
1507 }
1508 }
1509
1510 #[test]
1514 fn resolve_paths_populates_resolved_path() {
1515 let base = tempfile::TempDir::new().unwrap();
1516 let notes = base.path().join("notes");
1517 std::fs::create_dir_all(¬es).unwrap();
1518
1519 let toml = r#"
1520config_version = 2
1521[global]
1522current_workspace = "test"
1523[workspaces.test]
1524path = "notes"
1525last_paths = []
1526created = "2026-01-01T00:00:00Z"
1527"#
1528 .to_string();
1529 let mut settings: AppSettings = toml::from_str(&toml).unwrap();
1530 settings.resolve_paths(&SystemPath::try_absolute(base.path()).unwrap());
1531
1532 let wc = settings.workspace_config.as_ref().unwrap();
1533 let entry = wc.workspaces.get("test").unwrap();
1534 assert_eq!(entry.path, PathBuf::from("notes"));
1536 assert!(entry.resolved_path.is_some());
1538 assert!(entry.effective_path().is_absolute());
1539 }
1540
1541 #[test]
1551 fn load_from_file_resolves_paths_for_a_config_that_does_not_exist_yet() {
1552 let dir = tempfile::TempDir::new().unwrap();
1553 let config_dir = dir.path().canonicalize().unwrap();
1554 let settings = AppSettings::load_from_file(config_dir.join("config.toml")).unwrap();
1555
1556 let cache = settings.index_for("work");
1557 let history = settings.history_for("work");
1558 assert!(
1559 cache.path().as_path().starts_with(&config_dir),
1560 "cache path must sit next to the config file, got {cache}"
1561 );
1562 assert!(
1563 history.path().as_path().starts_with(&config_dir),
1564 "history path must sit next to the config file, got {history}"
1565 );
1566 }
1567
1568 #[test]
1572 fn load_from_file_resolves_paths_when_the_config_is_corrupt() {
1573 let dir = tempfile::TempDir::new().unwrap();
1574 let config_dir = dir.path().canonicalize().unwrap();
1575 let config_path = config_dir.join("config.toml");
1576 std::fs::write(&config_path, "not = valid toml [[[").unwrap();
1577
1578 let settings = AppSettings::load_from_file(config_path).unwrap();
1579
1580 let cache = settings.index_for("work");
1581 assert!(
1582 cache.path().as_path().starts_with(&config_dir),
1583 "cache path must sit next to the config file, got {cache}"
1584 );
1585 }
1586
1587 #[test]
1588 fn resolve_paths_absolute_no_resolved_path() {
1589 let toml = format!(
1593 r#"
1594config_version = 2
1595[global]
1596current_workspace = "test"
1597[workspaces.test]
1598path = "{}"
1599last_paths = []
1600created = "2026-01-01T00:00:00Z"
1601"#,
1602 absolute_toml("/absolute/notes")
1603 );
1604 let mut settings: AppSettings = toml::from_str(&toml).unwrap();
1605 settings.resolve_paths(&SystemPath::try_absolute(absolute("/config")).unwrap());
1606
1607 let wc = settings.workspace_config.as_ref().unwrap();
1608 let entry = wc.workspaces.get("test").unwrap();
1609 assert!(entry.resolved_path.is_none());
1611 assert_eq!(*entry.effective_path(), absolute("/absolute/notes"));
1612 }
1613}
1614
1615#[cfg(test)]
1616mod sort_settings_tests {
1617 use super::*;
1618
1619 #[test]
1620 fn group_directories_defaults_off() {
1621 let s = AppSettings::default();
1622 assert!(!s.group_directories);
1623 }
1624
1625 #[test]
1626 fn open_sort_dialog_is_bound_by_default() {
1627 let s = AppSettings::default();
1628 let map = s.key_bindings.to_hashmap();
1629 assert!(
1630 map.contains_key(&ActionShortcuts::OpenSortDialog),
1631 "OpenSortDialog must have a default binding"
1632 );
1633 }
1634}