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&B\"] # Ctrl+B (leader gateway: Ctrl+B 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()
249 .with_ctrl()
250 .add(KeyStrike::KeyK, ActionShortcuts::SearchNotes)
251 .add(KeyStrike::KeyO, ActionShortcuts::OpenNote);
252
253 kb.batch_add()
257 .with_ctrl()
258 .add(KeyStrike::KeyP, ActionShortcuts::OpenCommandPalette)
261 .add(KeyStrike::KeyQ, ActionShortcuts::Quit)
262 .add(KeyStrike::KeyJ, ActionShortcuts::NewJournal)
263 .add(KeyStrike::KeyT, ActionShortcuts::ToggleSidebar)
271 .add(KeyStrike::KeyR, ActionShortcuts::OpenSortDialog)
272 .add(KeyStrike::KeyB, ActionShortcuts::Leader)
280 .add(KeyStrike::KeyN, ActionShortcuts::FollowLink)
283 .add(KeyStrike::KeyH, ActionShortcuts::FocusSidebar)
284 .add(KeyStrike::KeyL, ActionShortcuts::FocusEditor)
285 .add(KeyStrike::KeyW, ActionShortcuts::QuickNote)
286 .add(KeyStrike::KeyE, ActionShortcuts::OpenFileBrowser)
290 .add(KeyStrike::KeyF, ActionShortcuts::FindInBuffer)
291 .add(
296 crate::keys::default_yank_combo().key,
297 ActionShortcuts::YankRow,
298 );
299
300 kb.batch_add()
306 .add(KeyStrike::F4, ActionShortcuts::OpenPreferences);
307 kb.batch_add()
308 .with_ctrl()
309 .add(KeyStrike::Comma, ActionShortcuts::OpenPreferences);
310
311 kb.batch_add()
313 .add(KeyStrike::F2, ActionShortcuts::FileOperations);
314
315 kb.batch_add()
316 .add(KeyStrike::F3, ActionShortcuts::OpenSavedSearches);
317
318 kb.batch_add().add(KeyStrike::F6, ActionShortcuts::OpenAsk);
320
321 kb.batch_add()
323 .add(KeyStrike::F5, ActionShortcuts::SwitchWorkspace);
324
325 kb.batch_add()
332 .with_ctrl()
333 .add(KeyStrike::KeyD, ActionShortcuts::SaveCurrentQuery);
334
335 kb
336}
337
338pub fn delete_artifacts(index: &IndexFile, history: &HistoryFile) -> Vec<String> {
352 let mut leftovers = Vec::new();
353 let stuck = index.remove();
357 if stuck.is_empty() {
358 tracing::info!("removed index {}", index);
359 }
360 for (path, e) in stuck {
361 tracing::warn!("failed to remove {}: {}", path, e);
362 leftovers.push(format!(" {path}\n {e}"));
363 }
364 if let Err(e) = history.remove() {
365 tracing::warn!("failed to remove history {}: {}", history, e);
366 leftovers.push(format!(" {history}\n {e}"));
367 } else {
368 tracing::info!("removed history {}", history);
369 }
370 leftovers
371}
372
373fn yes() -> bool {
374 true
375}
376
377fn default_autosave_interval() -> u64 {
378 5
379}
380
381fn default_leader_timeout_ms() -> u64 {
382 400
383}
384
385#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
387pub struct LeaderConfig {
388 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
391 pub bind: std::collections::BTreeMap<String, String>,
392 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
395 pub labels: std::collections::BTreeMap<String, String>,
396}
397
398impl AppSettings {
399 pub fn default_workspace_suggestion() -> Option<PathBuf> {
402 system::home()
403 .ok()
404 .map(|h| h.join("kimun-notes").into_path_buf())
405 }
406
407 pub fn leader_tree(&self) -> crate::keys::leader::LeaderNode {
411 let tree = crate::keys::leader::apply_overrides(
412 crate::keys::leader::leader_tree(),
413 self.leader
414 .bind
415 .iter()
416 .map(|(k, v)| (k.as_str(), v.as_str())),
417 );
418 crate::keys::leader::apply_labels(
419 tree,
420 self.leader
421 .labels
422 .iter()
423 .map(|(k, v)| (k.as_str(), v.as_str())),
424 )
425 }
426}
427
428fn default_cache_dir() -> PathBuf {
429 PathBuf::from(".")
430}
431
432fn default_history_dir() -> PathBuf {
433 PathBuf::from("history")
434}
435
436fn default_settings_base_dir() -> SystemPath {
448 system::app_dir().unwrap_or_else(|_| {
449 let fallback = std::env::temp_dir().join("kimun");
450 SystemPath::try_absolute(&fallback).unwrap_or_else(|_| system::log_dir())
451 })
452}
453
454fn default_cache_dir_resolved() -> SystemPath {
458 SystemPath::resolve(default_cache_dir(), &default_settings_base_dir())
459}
460
461fn default_history_dir_resolved() -> SystemPath {
463 SystemPath::resolve(default_history_dir(), &default_settings_base_dir())
464}
465
466fn default_use_nerd_fonts() -> bool {
467 false
468}
469
470fn default_sort_field() -> SortFieldSetting {
471 SortFieldSetting::Name
472}
473
474fn default_sort_order() -> SortOrderSetting {
475 SortOrderSetting::Ascending
476}
477
478fn default_journal_sort_field() -> SortFieldSetting {
479 SortFieldSetting::Name
480}
481
482fn default_journal_sort_order() -> SortOrderSetting {
483 SortOrderSetting::Descending
484}
485
486impl Default for AppSettings {
487 fn default() -> Self {
488 Self {
489 config_version: 0,
490 workspace_config: None,
491 theme: Default::default(),
492 cache_dir: default_cache_dir(),
493 cache_dir_resolved: default_cache_dir_resolved(),
494 history_dir: default_history_dir(),
495 history_dir_resolved: default_history_dir_resolved(),
496 needs_indexing: true,
497 key_bindings: default_keybindings(),
498 autosave_interval_secs: default_autosave_interval(),
499 leader_timeout_ms: default_leader_timeout_ms(),
500 leader: LeaderConfig::default(),
501 use_nerd_fonts: false,
502 editor_backend: EditorBackendSetting::Plain,
503 nvim_path: None,
504 default_sort_field: default_sort_field(),
505 default_sort_order: default_sort_order(),
506 journal_sort_field: default_journal_sort_field(),
507 journal_sort_order: default_journal_sort_order(),
508 group_directories: false,
509 ctrl_h: CtrlHSetting::default(),
510 config_file: None,
511 }
512 }
513}
514
515impl AppSettings {
516 pub fn theme_list(&self) -> Vec<Theme> {
517 let mut list = Theme::builtins();
518 list.append(&mut Self::load_custom_themes());
519 if let Ok(custom_default) = Self::load_default_theme() {
521 list.push(custom_default);
522 }
523 list.sort_by(|a, b| a.name.cmp(&b.name));
524 list
525 }
526
527 fn default_config_file_path() -> Result<PathBuf, SettingsError> {
528 Ok(system::ensure_app_dir()?
529 .join(BASE_CONFIG_FILE)
530 .into_path_buf())
531 }
532
533 fn get_config_file_path(&self) -> Result<PathBuf, SettingsError> {
534 if let Some(ref path) = self.config_file {
535 Ok(path.clone())
536 } else {
537 Self::default_config_file_path()
538 }
539 }
540
541 fn get_themes_path() -> Result<PathBuf, SettingsError> {
542 Ok(system::ensure_app_dir()?.join(THEMES_DIR).into_path_buf())
543 }
544
545 fn load_theme_from_path(path: &std::path::Path) -> Result<Theme, SettingsError> {
546 let theme_string = fs::read_to_string(path)?;
547 match toml::from_str::<Theme>(&theme_string) {
548 Ok(theme) => Ok(theme),
549 Err(e) => {
550 tracing::warn!("Skipping unparsable theme file {:?}: {}", path, e);
553 Err(SettingsError::CorruptTheme(e))
554 }
555 }
556 }
557
558 fn load_default_theme() -> Result<Theme, SettingsError> {
559 let theme_path = AppSettings::get_themes_path()?.join("default.toml");
560 Self::load_theme_from_path(&theme_path)
561 }
562
563 fn load_custom_themes() -> Vec<Theme> {
564 let mut themes = Vec::new();
565
566 let themes_path = match Self::get_themes_path() {
568 Ok(path) => path,
569 Err(_) => return themes,
570 };
571
572 let entries =
574 match SystemPath::try_absolute(&themes_path).and_then(|dir| system::read_dir(&dir)) {
575 Ok(entries) => entries,
576 Err(_) => return themes,
577 };
578
579 for entry in entries {
581 let path = entry.into_path_buf();
582
583 if !path.is_file() {
585 continue;
586 }
587
588 if path.extension().and_then(|s| s.to_str()) != Some("toml") {
590 continue;
591 }
592
593 if path.file_name().and_then(|s| s.to_str()) == Some("default.toml") {
595 continue;
596 }
597
598 match fs::read_to_string(&path)
600 .and_then(|s| toml::from_str::<Theme>(&s).map_err(std::io::Error::other))
601 {
602 Ok(theme) => themes.push(theme),
603 Err(e) => tracing::warn!("Skipping theme file {:?}: {}", path, e),
604 }
605 }
606
607 themes
608 }
609
610 pub fn update_check(&self) -> bool {
614 self.workspace_config
615 .as_ref()
616 .map(|wc| wc.global.update_check)
617 .unwrap_or(true)
618 }
619
620 pub fn mouse(&self) -> bool {
623 self.workspace_config
624 .as_ref()
625 .map(|wc| wc.global.mouse)
626 .unwrap_or(true)
627 }
628
629 pub fn save_to_disk(&self) -> Result<(), SettingsError> {
630 tracing::debug!("Saving settings to disk");
631 let settings_file_path = self.get_config_file_path()?;
632 let body = format!("{CONFIG_HEADER}{}", toml::to_string(&self)?);
636 system::replace_atomically(&settings_file_path, body.as_bytes())?;
637 Ok(())
638 }
639
640 pub fn load_from_disk() -> Result<Self, SettingsError> {
641 let settings_file_path = Self::default_config_file_path()?;
642
643 if !settings_file_path.exists() {
644 let default_settings = Self::defaults_for_config_file(settings_file_path);
645 default_settings.save_to_disk()?;
646 Ok(default_settings)
647 } else {
648 let mut settings_file = File::open(&settings_file_path)?;
649
650 let mut toml = String::new();
651 settings_file.read_to_string(&mut toml)?;
652
653 match toml::from_str::<AppSettings>(toml.as_ref()) {
654 Ok(mut setting) => {
655 setting.config_file = Some(settings_file_path.clone());
656 setting.resolve_paths(&Self::config_base_dir(&settings_file_path));
659 if config_migration::ConfigMigration::run(&mut setting)? {
660 setting.save_to_disk()?;
661 }
662 setting.merge_missing_default_bindings();
663 Ok(setting)
664 }
665 Err(e) => {
666 tracing::warn!(
667 "Config file at {:?} could not be parsed ({}). \
668 Renaming to .corrupt and starting with defaults.",
669 settings_file_path,
670 e
671 );
672 let corrupt_path = settings_file_path.with_extension("toml.corrupt");
673 let _ = system::move_file(&settings_file_path, &corrupt_path);
674 let defaults = Self::defaults_for_config_file(settings_file_path);
675 defaults.save_to_disk()?;
676 Ok(defaults)
677 }
678 }
679 }
680 }
681
682 pub fn load_from_file(path: PathBuf) -> Result<Self, SettingsError> {
683 if let Some(parent) = path.parent()
688 && !parent.as_os_str().is_empty()
689 {
690 system::create_dir(parent)?;
691 }
692 if !path.exists() {
693 let default_settings = Self::defaults_for_config_file(path);
694 default_settings.save_to_disk()?;
695 return Ok(default_settings);
696 }
697 let mut toml_str = String::new();
698 File::open(&path)?.read_to_string(&mut toml_str)?;
699 match toml::from_str::<AppSettings>(&toml_str) {
700 Ok(mut setting) => {
701 setting.config_file = Some(path.clone());
702
703 setting.resolve_paths(&Self::config_base_dir(&path));
706
707 if config_migration::ConfigMigration::run(&mut setting)? {
709 setting.save_to_disk()?;
710 }
711
712 setting.merge_missing_default_bindings();
713 Ok(setting)
714 }
715 Err(e) => {
716 tracing::warn!(
717 "Config file at {:?} could not be parsed ({}). \
718 Renaming to .corrupt and starting with defaults.",
719 path,
720 e
721 );
722 let corrupt_path = path.with_extension("toml.corrupt");
723 let _ = system::move_file(&path, &corrupt_path);
724 let defaults = Self::defaults_for_config_file(path);
725 defaults.save_to_disk()?;
726 Ok(defaults)
727 }
728 }
729 }
730
731 fn merge_missing_default_bindings(&mut self) {
737 let defaults = default_keybindings().to_hashmap();
738 let mut current = self.key_bindings.to_hashmap();
739 let mut bound: std::collections::HashSet<_> = current.values().flatten().cloned().collect();
740 for (action, combos) in defaults {
741 match current.entry(action) {
742 std::collections::hash_map::Entry::Vacant(e) => {
743 let free: Vec<_> = combos.into_iter().filter(|c| !bound.contains(c)).collect();
747 if !free.is_empty() {
748 bound.extend(free.iter().copied());
749 e.insert(free);
750 }
751 }
752 std::collections::hash_map::Entry::Occupied(mut e) => {
753 for combo in combos {
754 if !bound.contains(&combo) && !e.get().contains(&combo) {
755 bound.insert(combo);
756 e.get_mut().push(combo);
757 }
758 }
759 }
760 }
761 }
762 self.key_bindings = KeyBindings::from_hashmap(current);
763 }
764
765 pub fn set_workspace_path(&mut self, name: &str, workspace_path: PathBuf) {
770 let Some(entry) = self
771 .workspace_config
772 .as_mut()
773 .and_then(|wc| wc.workspaces.get_mut(name))
774 else {
775 return;
776 };
777 if *entry.effective_path() != workspace_path {
778 self.needs_indexing = true;
779 }
780 entry.path = workspace_path;
781 entry.resolved_path = None;
782 }
783
784 pub fn clear_workspace(&mut self) {
797 if let Some(wc) = &mut self.workspace_config {
798 let key = wc.global.current_workspace.clone();
799 if !key.is_empty() {
800 wc.workspaces.remove(&key);
801 }
802 wc.global.current_workspace = String::new();
803 }
804 }
805
806 pub fn resolve_workspace_path(&self) -> Option<SystemPath> {
809 let raw = self
810 .workspace_config
811 .as_ref()
812 .and_then(|wc| wc.get_current_workspace())
813 .map(|entry| entry.effective_path().clone())?;
814 match SystemPath::try_absolute(&raw) {
819 Ok(path) => Some(path),
820 Err(e) => {
821 tracing::warn!("ignoring unusable workspace path {raw:?}: {e}");
822 None
823 }
824 }
825 }
826
827 fn resolve_paths(&mut self, base: &SystemPath) {
831 if let Some(ref mut wc) = self.workspace_config {
833 for entry in wc.workspaces.values_mut() {
834 let resolved = SystemPath::resolve(&entry.path, base).into_path_buf();
835 if resolved != entry.path {
836 entry.resolved_path = Some(resolved);
837 }
838 }
839 }
840 self.cache_dir_resolved = SystemPath::resolve(&self.cache_dir, base);
841 self.history_dir_resolved = SystemPath::resolve(&self.history_dir, base);
842 }
843
844 fn defaults_for_config_file(path: PathBuf) -> Self {
855 let base = Self::config_base_dir(&path);
856 let mut settings = Self {
857 config_file: Some(path),
858 ..Self::default()
859 };
860 settings.resolve_paths(&base);
861 settings
862 }
863
864 fn config_base_dir(config_file: &std::path::Path) -> SystemPath {
882 let dir = config_file
883 .parent()
884 .filter(|p| !p.as_os_str().is_empty())
885 .unwrap_or(std::path::Path::new("."));
886 SystemPath::canonical(dir)
890 .or_else(|_| {
891 let cwd = std::env::current_dir().unwrap_or_default();
892 SystemPath::try_absolute(cwd.join(dir))
893 })
894 .unwrap_or_else(|_| default_settings_base_dir())
895 }
896
897 pub fn set_theme(&mut self, theme: String) {
898 self.theme = theme;
899 }
900
901 pub fn report_indexed(&mut self) {
902 self.needs_indexing = false;
903 }
904
905 pub fn needs_indexing(&self) -> bool {
906 self.needs_indexing
907 }
908
909 pub fn add_path_history(&self, note_path: &VaultPath) {
910 if !note_path.is_note() {
911 return;
912 }
913 let Some(history) = self.current_history() else {
914 return;
915 };
916 if let Err(e) = history.push(note_path) {
917 tracing::warn!("failed to write history {history}: {e}");
918 }
919 }
920
921 pub fn follow_rename_in_history(&self, from: &VaultPath, to: &VaultPath) {
925 self.edit_path_history(|paths| {
926 if from.is_note() {
927 pinned_notes::rewrite_note_rename(paths, from, to)
928 } else {
929 pinned_notes::rewrite_directory_rename(paths, from, to)
930 }
931 });
932 }
933
934 pub fn follow_delete_in_history(&self, path: &VaultPath) {
937 self.edit_path_history(|paths| {
938 if path.is_note() {
939 pinned_notes::remove(paths, path)
940 } else {
941 pinned_notes::remove_under_directory(paths, path)
942 }
943 });
944 }
945
946 pub fn edit_path_history(&self, f: impl FnOnce(&mut Vec<VaultPath>) -> bool) {
950 let Some(history) = self.current_history() else {
951 return;
952 };
953 if let Err(e) = history.edit(f) {
954 tracing::warn!("failed to write history {history}: {e}");
955 }
956 }
957
958 fn current_history(&self) -> Option<HistoryFile> {
960 self.current_workspace_name()
961 .map(|name| self.history_for(&name))
962 }
963
964 pub fn current_workspace_name(&self) -> Option<String> {
965 self.workspace_config
966 .as_ref()
967 .map(|wc| wc.global.current_workspace.clone())
968 .filter(|s| !s.is_empty())
969 }
970
971 pub fn cache_dir_resolved(&self) -> &SystemPath {
973 &self.cache_dir_resolved
974 }
975
976 pub fn history_dir_resolved(&self) -> &SystemPath {
978 &self.history_dir_resolved
979 }
980
981 fn file_key_for(&self, workspace_name: &str) -> String {
990 self.workspace_config
991 .as_ref()
992 .and_then(|wc| wc.get_workspace(workspace_name))
993 .map(|entry| entry.file_key_or(workspace_name))
994 .unwrap_or_else(|| workspace_name.to_string())
995 }
996
997 pub fn index_for(&self, workspace_name: &str) -> IndexFile {
1004 IndexFile::in_dir(&self.cache_dir_resolved, &self.file_key_for(workspace_name))
1005 }
1006
1007 pub fn history_for(&self, workspace_name: &str) -> HistoryFile {
1011 HistoryFile::in_dir(
1012 &self.history_dir_resolved,
1013 &self.file_key_for(workspace_name),
1014 )
1015 }
1016
1017 pub fn workspace_artifacts(&self, workspace_name: &str) -> (IndexFile, HistoryFile) {
1027 (
1028 self.index_for(workspace_name),
1029 self.history_for(workspace_name),
1030 )
1031 }
1032
1033 pub fn current_last_paths(&self) -> Vec<VaultPath> {
1035 self.current_history()
1036 .map(|history| history.load())
1037 .unwrap_or_default()
1038 }
1039
1040 #[cfg(test)]
1046 pub(crate) fn for_test_workspace(
1047 name: &str,
1048 workspace: &SystemPath,
1049 scratch_dir: SystemPath,
1050 ) -> Self {
1051 let mut wc = WorkspaceConfig::new_empty();
1052 wc.add_workspace(name.to_string(), workspace.clone().into_path_buf())
1053 .unwrap();
1054 Self {
1055 workspace_config: Some(wc),
1056 config_file: Some(scratch_dir.join("config.toml").into_path_buf()),
1057 history_dir_resolved: scratch_dir,
1058 ..Self::default()
1059 }
1060 }
1061
1062 #[cfg(test)]
1064 pub(crate) fn seed_test_history(&self, paths: &[&str]) {
1065 let paths: Vec<VaultPath> = paths.iter().map(|p| VaultPath::new(*p)).collect();
1066 self.current_history().unwrap().write(&paths).unwrap();
1067 }
1068
1069 pub fn icons(&self) -> icons::Icons {
1071 icons::Icons::new(self.use_nerd_fonts)
1072 }
1073
1074 pub fn yank_combos(&self) -> Vec<crate::keys::key_combo::KeyCombo> {
1078 self.key_bindings.combos_for(&ActionShortcuts::YankRow)
1079 }
1080
1081 pub fn effective_theme_name(&self) -> String {
1085 if self.theme.is_empty() {
1086 Theme::default().name
1087 } else {
1088 self.theme.clone()
1089 }
1090 }
1091
1092 pub fn get_theme(&self) -> Theme {
1098 let theme = if self.theme.is_empty() {
1099 Theme::default()
1100 } else {
1101 self.theme_list()
1102 .into_iter()
1103 .find(|t| t.name == self.theme)
1104 .unwrap_or_default()
1105 };
1106 theme.adapt_to_terminal()
1107 }
1108}
1109
1110#[cfg(test)]
1112mod test_paths {
1113 use std::path::PathBuf;
1114
1115 pub(super) fn absolute(unix_style: &str) -> PathBuf {
1127 let trimmed = unix_style.trim_start_matches('/');
1128 if cfg!(windows) {
1129 PathBuf::from(format!("C:\\{}", trimmed.replace('/', "\\")))
1130 } else {
1131 PathBuf::from(format!("/{trimmed}"))
1132 }
1133 }
1134
1135 pub(super) fn absolute_toml(unix_style: &str) -> String {
1138 absolute(unix_style).to_string_lossy().replace('\\', "\\\\")
1139 }
1140}
1141
1142#[cfg(test)]
1143#[allow(clippy::field_reassign_with_default)]
1144mod tests {
1145 use super::test_paths::absolute;
1146 use super::*;
1147
1148 #[test]
1149 fn default_workspace_suggestion_is_under_home() {
1150 let suggestion = AppSettings::default_workspace_suggestion();
1151 if let Some(p) = suggestion {
1152 assert!(p.ends_with("kimun-notes"));
1153 assert!(p.is_absolute());
1154 }
1155 }
1157
1158 #[test]
1159 fn load_theme_from_nonexistent_path_returns_err_without_creating_file() {
1160 let path = std::env::temp_dir().join("kimun_tdd_test_theme_absent.toml");
1163 let _ = std::fs::remove_file(&path); let result = AppSettings::load_theme_from_path(&path);
1166
1167 assert!(result.is_err(), "should return Err when file is absent");
1168 assert!(!path.exists(), "must not create the file as a side effect");
1169 }
1170
1171 #[test]
1172 fn load_theme_from_corrupt_path_returns_err_without_recreating_file() {
1173 let path = std::env::temp_dir().join("kimun_tdd_test_theme_corrupt.toml");
1175 std::fs::write(&path, b"not valid toml {{{{").unwrap();
1176
1177 let result = AppSettings::load_theme_from_path(&path);
1178
1179 assert!(result.is_err(), "should return Err for corrupt TOML");
1180 assert!(path.exists(), "corrupt theme file must not be deleted");
1183 std::fs::remove_file(&path).ok();
1184 }
1185
1186 #[test]
1187 fn default_keybindings_quit_matches_canonical_combo() {
1188 let kb = default_keybindings();
1189 let combo = crate::keys::default_quit_combo();
1190 assert_eq!(
1191 kb.get_action(&combo),
1192 Some(ActionShortcuts::Quit),
1193 "default_keybindings() must bind default_quit_combo() to Quit so the \
1194 deserialize safety net can recover an unreachable app"
1195 );
1196 }
1197
1198 #[test]
1199 fn autosave_interval_defaults_to_five() {
1200 let settings = AppSettings::default();
1201 assert_eq!(settings.autosave_interval_secs, 5);
1202 }
1203
1204 #[test]
1205 fn autosave_interval_deserializes_from_toml() {
1206 let toml = "autosave_interval_secs = 30\n";
1207 let settings: AppSettings = toml::from_str(toml).unwrap();
1208 assert_eq!(settings.autosave_interval_secs, 30);
1209 }
1210
1211 #[test]
1212 fn autosave_interval_defaults_when_missing_from_toml() {
1213 let toml = ""; let settings: AppSettings = toml::from_str(toml).unwrap();
1215 assert_eq!(settings.autosave_interval_secs, 5);
1216 }
1217
1218 #[test]
1220 fn f2_file_operations_survives_toml_deserialize() {
1221 use crate::keys::key_combo::{KeyCombo, KeyModifiers};
1222 use crate::keys::key_strike::KeyStrike;
1223
1224 let toml = r#"
1225[key_bindings]
1226FileOperations = ["F2"]
1227"#;
1228 let settings: AppSettings = toml::from_str(toml).unwrap();
1229 let f2 = KeyCombo::new(KeyModifiers::default(), KeyStrike::F2);
1230 let action = settings.key_bindings.get_action(&f2);
1231 assert_eq!(
1232 action,
1233 Some(ActionShortcuts::FileOperations),
1234 "F2 should survive deserialization and map to FileOperations"
1235 );
1236 }
1237
1238 #[test]
1240 fn merge_adds_f2_when_absent() {
1241 use crate::keys::key_combo::{KeyCombo, KeyModifiers};
1242 use crate::keys::key_strike::KeyStrike;
1243
1244 let toml = r#"
1246[key_bindings]
1247Quit = ["ctrl&Q"]
1248"#;
1249 let mut settings: AppSettings = toml::from_str(toml).unwrap();
1250 settings.merge_missing_default_bindings();
1251
1252 let f2 = KeyCombo::new(KeyModifiers::default(), KeyStrike::F2);
1253 let action = settings.key_bindings.get_action(&f2);
1254 assert_eq!(
1255 action,
1256 Some(ActionShortcuts::FileOperations),
1257 "merge_missing_default_bindings should add F2 → FileOperations"
1258 );
1259 }
1260
1261 #[test]
1262 fn clear_workspace_removes_the_current_workspace_entry() {
1263 let mut settings = AppSettings::default();
1264 let mut wc = WorkspaceConfig::new_empty();
1265 wc.add_workspace("vault1".to_string(), absolute("/tmp/vault1"))
1266 .unwrap();
1267 settings.workspace_config = Some(wc);
1268 assert_eq!(
1270 settings
1271 .workspace_config
1272 .as_ref()
1273 .unwrap()
1274 .global
1275 .current_workspace,
1276 "vault1"
1277 );
1278 settings.clear_workspace();
1279 let wc = settings.workspace_config.as_ref().unwrap();
1280 assert!(
1281 wc.workspaces.is_empty(),
1282 "workspace entry should be removed"
1283 );
1284 assert!(
1285 wc.global.current_workspace.is_empty(),
1286 "current_workspace should be empty"
1287 );
1288 }
1289
1290 #[test]
1291 fn clear_workspace_preserves_other_workspaces() {
1292 let mut settings = AppSettings::default();
1293 let mut wc = WorkspaceConfig::new_empty();
1294 wc.add_workspace("vault1".to_string(), absolute("/tmp/vault1"))
1295 .unwrap();
1296 wc.add_workspace("vault2".to_string(), PathBuf::from("/tmp/vault2"))
1297 .unwrap();
1298 wc.global.current_workspace = "vault1".to_string();
1299 settings.workspace_config = Some(wc);
1300 settings.clear_workspace();
1301 let wc = settings.workspace_config.as_ref().unwrap();
1302 assert!(
1303 !wc.workspaces.contains_key("vault1"),
1304 "active workspace should be removed"
1305 );
1306 assert!(
1307 wc.workspaces.contains_key("vault2"),
1308 "other workspaces should be preserved"
1309 );
1310 assert!(
1311 wc.global.current_workspace.is_empty(),
1312 "current_workspace should be empty"
1313 );
1314 }
1315}
1316
1317#[cfg(test)]
1318mod backend_tests {
1319 use super::test_paths::{absolute, absolute_toml};
1320 use super::*;
1321
1322 #[test]
1323 fn a_config_still_saying_textarea_loads() {
1324 #[derive(serde::Deserialize)]
1328 struct Holder {
1329 editor_backend: EditorBackendSetting,
1330 }
1331 let old: Holder = toml::from_str("editor_backend = \"textarea\"").expect("still loads");
1332 assert_eq!(old.editor_backend, EditorBackendSetting::Plain);
1333 }
1334
1335 #[test]
1336 fn the_backend_is_written_back_as_plain() {
1337 let written = toml::to_string(&AppSettings::default()).expect("serialises");
1338 assert!(
1339 written.contains("editor_backend = \"plain\""),
1340 "a saved config should name the value as it is now: {written}"
1341 );
1342 }
1343
1344 #[test]
1345 fn default_backend_is_plain() {
1346 let settings = AppSettings::default();
1347 assert!(matches!(
1348 settings.editor_backend,
1349 EditorBackendSetting::Plain
1350 ));
1351 }
1352
1353 #[test]
1354 fn nvim_backend_round_trips_toml() {
1355 let toml = "editor_backend = \"nvim\"\n";
1356 let parsed: AppSettings = toml::from_str(toml).unwrap();
1357 assert!(matches!(parsed.editor_backend, EditorBackendSetting::Nvim));
1358 }
1359
1360 #[test]
1361 fn editor_backend_vim_roundtrips_through_toml() {
1362 #[derive(serde::Serialize, serde::Deserialize)]
1363 struct W {
1364 editor_backend: EditorBackendSetting,
1365 }
1366 let w = W {
1367 editor_backend: EditorBackendSetting::Vim,
1368 };
1369 let s = toml::to_string(&w).unwrap();
1370 assert!(s.contains("editor_backend = \"vim\""), "serialized: {s}");
1371 let back: W = toml::from_str(&s).unwrap();
1372 assert_eq!(back.editor_backend, EditorBackendSetting::Vim);
1373 }
1374
1375 #[test]
1386 fn every_default_action_keeps_a_chord_a_legacy_terminal_can_send() {
1387 use crate::keys::reachability::{TerminalKeys, reach};
1388
1389 for (action, combos) in default_keybindings().to_hashmap() {
1390 let verdicts: Vec<String> = combos
1391 .iter()
1392 .map(|c| format!("{c}: {}", reach(*c, TerminalKeys::LEGACY)))
1393 .collect();
1394 assert!(
1395 combos
1396 .iter()
1397 .any(|c| reach(*c, TerminalKeys::LEGACY).is_ok()),
1398 "{action} has no chord a legacy terminal can send: {}",
1399 verdicts.join("; ")
1400 );
1401 }
1402 }
1403
1404 #[test]
1410 fn an_unreachable_chord_is_allowed_as_an_alias() {
1411 use crate::keys::key_combo::{KeyCombo, KeyModifiers};
1412 use crate::keys::reachability::{Reach, TerminalKeys, reach};
1413
1414 let kb = default_keybindings();
1415 let combos = kb.combos_for(&ActionShortcuts::OpenPreferences);
1416 let ctrl_comma = KeyCombo::new(KeyModifiers::new().and_ctrl(), KeyStrike::Comma);
1417 assert!(combos.contains(&ctrl_comma), "the alias is still bound");
1418 assert_eq!(
1419 reach(ctrl_comma, TerminalKeys::LEGACY),
1420 Reach::Untransmitted,
1421 "and it is still the unreachable one"
1422 );
1423 assert!(
1424 combos.contains(&KeyCombo::new(KeyModifiers::new(), KeyStrike::F4)),
1425 "F4 is what actually carries Preferences"
1426 );
1427 }
1428
1429 #[test]
1437 fn the_default_table_binds_no_text_action() {
1438 let kb = default_keybindings();
1439 let bound: Vec<String> = kb
1440 .to_hashmap()
1441 .keys()
1442 .filter_map(|a| match a {
1443 ActionShortcuts::Text(t) => Some(format!("{t:?}")),
1444 _ => None,
1445 })
1446 .collect();
1447 assert!(
1448 bound.is_empty(),
1449 "formatting belongs on the leader, but these still hold chords: {bound:?}"
1450 );
1451 }
1452
1453 #[test]
1459 fn ctrl_l_and_ctrl_t_belong_to_focus_and_the_drawer() {
1460 let kb = default_keybindings();
1461 use crate::keys::key_combo::{KeyCombo, KeyModifiers};
1462 let ctrl = |key| KeyCombo::new(KeyModifiers::new().and_ctrl(), key);
1463 assert_eq!(
1464 kb.get_action(&ctrl(KeyStrike::KeyL)),
1465 Some(ActionShortcuts::FocusEditor)
1466 );
1467 assert_eq!(
1468 kb.get_action(&ctrl(KeyStrike::KeyT)),
1469 Some(ActionShortcuts::ToggleSidebar)
1470 );
1471 }
1472
1473 #[test]
1476 fn ctrl_h_parses_and_defaults_to_auto() {
1477 for (written, expected) in [
1478 ("auto", CtrlHSetting::Auto),
1479 ("backspace", CtrlHSetting::Backspace),
1480 ("chord", CtrlHSetting::Chord),
1481 ] {
1482 let s = toml::from_str::<AppSettings>(&format!("ctrl_h = \"{written}\"\n"))
1483 .unwrap_or_else(|e| panic!("ctrl_h = \"{written}\" must parse: {e}"));
1484 assert_eq!(s.ctrl_h, expected);
1485 }
1486 assert_eq!(
1487 toml::from_str::<AppSettings>("theme = \"gruvbox_dark\"\n")
1488 .unwrap()
1489 .ctrl_h,
1490 CtrlHSetting::Auto,
1491 "a config written before the setting existed must not change behaviour"
1492 );
1493 }
1494
1495 #[test]
1501 fn workspace_paths_are_absolute_without_resolve_paths() {
1502 for settings in [
1503 AppSettings::default(),
1504 toml::from_str::<AppSettings>("theme = \"gruvbox_dark\"\n").unwrap(),
1505 ] {
1506 let cache = settings.index_for("w");
1507 let history = settings.history_for("w");
1508 assert!(
1509 cache.path().as_path().is_absolute(),
1510 "cache path not absolute: {cache}"
1511 );
1512 assert!(
1513 history.path().as_path().is_absolute(),
1514 "history path not absolute: {history}"
1515 );
1516 }
1517 }
1518
1519 #[test]
1523 fn resolve_paths_populates_resolved_path() {
1524 let base = tempfile::TempDir::new().unwrap();
1525 let notes = base.path().join("notes");
1526 std::fs::create_dir_all(¬es).unwrap();
1527
1528 let toml = r#"
1529config_version = 2
1530[global]
1531current_workspace = "test"
1532[workspaces.test]
1533path = "notes"
1534last_paths = []
1535created = "2026-01-01T00:00:00Z"
1536"#
1537 .to_string();
1538 let mut settings: AppSettings = toml::from_str(&toml).unwrap();
1539 settings.resolve_paths(&SystemPath::try_absolute(base.path()).unwrap());
1540
1541 let wc = settings.workspace_config.as_ref().unwrap();
1542 let entry = wc.workspaces.get("test").unwrap();
1543 assert_eq!(entry.path, PathBuf::from("notes"));
1545 assert!(entry.resolved_path.is_some());
1547 assert!(entry.effective_path().is_absolute());
1548 }
1549
1550 #[test]
1560 fn load_from_file_resolves_paths_for_a_config_that_does_not_exist_yet() {
1561 let dir = tempfile::TempDir::new().unwrap();
1562 let config_dir = dir.path().canonicalize().unwrap();
1563 let settings = AppSettings::load_from_file(config_dir.join("config.toml")).unwrap();
1564
1565 let cache = settings.index_for("work");
1566 let history = settings.history_for("work");
1567 assert!(
1568 cache.path().as_path().starts_with(&config_dir),
1569 "cache path must sit next to the config file, got {cache}"
1570 );
1571 assert!(
1572 history.path().as_path().starts_with(&config_dir),
1573 "history path must sit next to the config file, got {history}"
1574 );
1575 }
1576
1577 #[test]
1581 fn load_from_file_resolves_paths_when_the_config_is_corrupt() {
1582 let dir = tempfile::TempDir::new().unwrap();
1583 let config_dir = dir.path().canonicalize().unwrap();
1584 let config_path = config_dir.join("config.toml");
1585 std::fs::write(&config_path, "not = valid toml [[[").unwrap();
1586
1587 let settings = AppSettings::load_from_file(config_path).unwrap();
1588
1589 let cache = settings.index_for("work");
1590 assert!(
1591 cache.path().as_path().starts_with(&config_dir),
1592 "cache path must sit next to the config file, got {cache}"
1593 );
1594 }
1595
1596 #[test]
1597 fn resolve_paths_absolute_no_resolved_path() {
1598 let toml = format!(
1602 r#"
1603config_version = 2
1604[global]
1605current_workspace = "test"
1606[workspaces.test]
1607path = "{}"
1608last_paths = []
1609created = "2026-01-01T00:00:00Z"
1610"#,
1611 absolute_toml("/absolute/notes")
1612 );
1613 let mut settings: AppSettings = toml::from_str(&toml).unwrap();
1614 settings.resolve_paths(&SystemPath::try_absolute(absolute("/config")).unwrap());
1615
1616 let wc = settings.workspace_config.as_ref().unwrap();
1617 let entry = wc.workspaces.get("test").unwrap();
1618 assert!(entry.resolved_path.is_none());
1620 assert_eq!(*entry.effective_path(), absolute("/absolute/notes"));
1621 }
1622}
1623
1624#[cfg(test)]
1625mod sort_settings_tests {
1626 use super::*;
1627
1628 #[test]
1629 fn group_directories_defaults_off() {
1630 let s = AppSettings::default();
1631 assert!(!s.group_directories);
1632 }
1633
1634 #[test]
1635 fn open_sort_dialog_is_bound_by_default() {
1636 let s = AppSettings::default();
1637 let map = s.key_bindings.to_hashmap();
1638 assert!(
1639 map.contains_key(&ActionShortcuts::OpenSortDialog),
1640 "OpenSortDialog must have a default binding"
1641 );
1642 }
1643}