1use std::path::PathBuf;
2
3use async_trait::async_trait;
4use kimun_core::error::VaultError;
5use kimun_core::{NoteVault, NotesValidation, VaultConfig};
6use ratatui::Frame;
7use ratatui::crossterm::event::{KeyCode, KeyModifiers};
8use ratatui::layout::{Constraint, Direction, Layout};
9use ratatui::style::{Modifier, Style};
10use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap};
11use throbber_widgets_tui::ThrobberState;
12
13use crate::app_screen::{AppScreen, ScreenKind};
14use crate::components::Component;
15use crate::components::event_state::EventState;
16use crate::components::events::{AppEvent, AppTx, InputEvent};
17use crate::components::indexing::{
18 IndexingProgressState, fixed_centered_rect, render_indexing_overlay, spawn_running,
19};
20use crate::components::preferences::appearance_section::AppearanceSection;
21use crate::components::preferences::display_section::DisplaySection;
22use crate::components::preferences::editor_section::EditorSection;
23use crate::components::preferences::indexing_section::IndexingSection;
24use crate::components::preferences::server_section::ServerSection;
25use crate::components::preferences::sorting_section::SortingSection;
26use crate::components::preferences::workspaces_section::{
27 Mode as WorkspaceMode, WorkspacesSection,
28};
29use crate::settings::AppSettings;
30use crate::settings::SharedSettings;
31use crate::settings::config_migration::CURRENT_CONFIG_VERSION;
32use crate::settings::themes::Theme;
33
34use crate::components::dir_browser::FileBrowserState;
35
36#[derive(Debug, PartialEq)]
39pub enum ConfirmButton {
40 Cancel,
41 Confirm,
42}
43
44#[derive(Debug, PartialEq)]
45pub enum SaveButton {
46 Save,
47 Discard,
48}
49
50pub enum Overlay {
51 None,
52 FileBrowser(FileBrowserState),
53 ConfirmFullReindex {
54 focused_button: ConfirmButton,
55 },
56 ConfirmSave {
57 focused_button: SaveButton,
58 },
59 IndexingProgress(IndexingProgressState),
60 VaultConflict(String),
63}
64
65#[derive(Debug, Clone, Copy, PartialEq)]
68enum PreferencesSection {
69 Workspaces,
70 Appearance,
71 Display,
72 Sorting,
73 Indexing,
74 Editor,
75 Server,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq)]
79enum PreferencesFocus {
80 Sidebar,
81 Content,
82}
83
84pub struct PreferencesScreen {
87 pub settings: SharedSettings,
88 pub initial_settings: AppSettings,
89 pub theme: Theme,
90 section: PreferencesSection,
91 focus: PreferencesFocus,
92 appearance_section: AppearanceSection,
93 display_section: DisplaySection,
94 sorting_section: SortingSection,
95 workspaces_section: WorkspacesSection,
96 pending_create_name: Option<String>,
97 indexing_section: IndexingSection,
98 editor_section: EditorSection,
99 server_section: ServerSection,
100 pub overlay: Overlay,
101 pub pending_save_after_index: bool,
102 throbber_state: ThrobberState,
103}
104
105impl PreferencesScreen {
106 pub fn new(settings: SharedSettings) -> Self {
107 let s = settings.read().unwrap();
108 let themes = s.theme_list();
111 let theme = themes
112 .iter()
113 .find(|t| t.name == s.theme)
114 .cloned()
115 .unwrap_or_default()
116 .adapt_to_terminal();
117 let active_name = theme.name.clone();
118 let vault_available = s
119 .workspace_config
120 .as_ref()
121 .is_some_and(|wc| wc.get_current_workspace().is_some());
122 let autosave_interval_secs = s.autosave_interval_secs;
123 let editor_backend = s.editor_backend;
124 let use_nerd_fonts = s.use_nerd_fonts;
125 let update_check = s.update_check();
126 let mouse = s.mouse();
127 let leader_timeout_ms = s.leader_timeout_ms;
128 let server_url = s
129 .workspace_config
130 .as_ref()
131 .and_then(|wc| wc.global.kimun_server_url.clone());
132 let initial_settings = s.clone();
133 let workspaces_section = WorkspacesSection::new(&s);
134 let sorting_section = SortingSection::new(
135 s.default_sort_field,
136 s.default_sort_order,
137 s.journal_sort_field,
138 s.journal_sort_order,
139 );
140 drop(s);
141 Self {
142 appearance_section: AppearanceSection::new(themes, &active_name),
143 display_section: DisplaySection::new(
144 use_nerd_fonts,
145 update_check,
146 mouse,
147 leader_timeout_ms,
148 ),
149 sorting_section,
150 workspaces_section,
151 pending_create_name: None,
152 indexing_section: IndexingSection::new(vault_available),
153 editor_section: EditorSection::new(autosave_interval_secs, editor_backend),
154 server_section: ServerSection::new(server_url),
155 settings,
156 initial_settings,
157 theme,
158 section: PreferencesSection::Workspaces,
159 focus: PreferencesFocus::Sidebar,
160 overlay: Overlay::None,
161 pending_save_after_index: false,
162 throbber_state: ThrobberState::default(),
163 }
164 }
165
166 pub fn new_with_error(settings: SharedSettings, error: String) -> Self {
173 let mut s = Self::new(settings);
174 s.overlay = Overlay::VaultConflict(error);
175 s
176 }
177
178 fn do_save(&mut self, tx: &AppTx) {
179 let s = self.settings.read().unwrap();
180 let current_path = s.resolve_workspace_path();
181 let initial_path = self.initial_settings.resolve_workspace_path();
182 if current_path != initial_path {
183 let Some(workspace) = current_path else {
184 drop(s);
185 tx.send(AppEvent::IndexingDone(Err("No workspace set".to_string())))
186 .ok();
187 return;
188 };
189 let workspace_name = s
190 .workspace_config
191 .as_ref()
192 .map(|wc| wc.global.current_workspace.clone())
193 .filter(|n| !n.is_empty());
194 let cache_path = workspace_name.as_ref().map(|n| s.index_for(n));
195 drop(s);
196 self.pending_save_after_index = true;
197 let tx2 = tx.clone();
198 let handle = tokio::spawn(async move {
199 let mut config = VaultConfig::new(workspace);
200 if let Some(path) = cache_path {
201 config = config.with_index(path);
202 }
203 let event = match NoteVault::new(config).await {
204 Err(e) => AppEvent::IndexingDone(Err(e.to_string())),
205 Ok(vault) => {
206 let result = vault.recreate_index().await;
207 vault.close().await;
211 match result {
212 Ok(r) => AppEvent::IndexingDone(Ok(r.duration)),
213 Err(e @ VaultError::CaseConflict { .. }) => {
214 AppEvent::VaultConflict(e.to_string())
215 }
216 Err(e) => AppEvent::IndexingDone(Err(e.to_string())),
217 }
218 }
219 };
220 tx2.send(event).ok();
221 });
222 self.overlay = Overlay::IndexingProgress(spawn_running(handle, tx));
223 } else {
224 s.save_to_disk().ok();
225 drop(s);
226 tx.send(AppEvent::PreferencesSaved).ok();
227 }
228 }
229
230 fn confirm_file_browser(&mut self, chosen: PathBuf, _tx: &AppTx) {
232 use crate::settings::workspace_config::WorkspaceConfig;
233
234 if let Some(name) = self.pending_create_name.take() {
235 let name = name.to_lowercase();
236 {
237 let mut s = self.settings.write().unwrap();
238 if s.workspace_config.is_none() {
239 s.workspace_config = Some(WorkspaceConfig::new_empty());
240 s.config_version = CURRENT_CONFIG_VERSION;
241 }
242 if let Some(ref mut wc) = s.workspace_config
243 && let Err(e) = wc.add_workspace(name.clone(), chosen)
244 {
245 tracing::warn!("rejected workspace add: {}", e);
246 }
247 }
248 self.workspaces_section
249 .refresh(&self.settings.read().unwrap());
250 self.indexing_section.set_vault_available(true);
251 } else {
252 {
254 let mut s = self.settings.write().unwrap();
255 if let Some(name) = self
260 .workspaces_section
261 .selected_name()
262 .map(|s| s.to_string())
263 {
264 s.set_workspace_path(&name, chosen);
265 }
266 }
267 self.workspaces_section
268 .refresh(&self.settings.read().unwrap());
269 self.indexing_section.set_vault_available(true);
270 }
271 self.overlay = Overlay::None;
272 }
273}
274
275#[async_trait]
278impl AppScreen for PreferencesScreen {
279 fn get_kind(&self) -> ScreenKind {
280 ScreenKind::Preferences
281 }
282
283 fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
284 match &mut self.overlay {
286 Overlay::None => {}
287
288 Overlay::FileBrowser(fb) => {
289 let InputEvent::Key(key) = event else {
290 return EventState::NotConsumed;
291 };
292 let offset = if fb.has_parent { 1 } else { 0 };
293 let total = fb.entries.len() + offset;
294 match key.code {
295 KeyCode::Esc => {
296 self.overlay = Overlay::None;
297 }
298 KeyCode::Up if total > 0 => {
299 let cur = fb.list_state.selected().unwrap_or(0);
300 fb.list_state.select(Some((cur + total - 1) % total));
301 }
302 KeyCode::Down if total > 0 => {
303 let cur = fb.list_state.selected().unwrap_or(0);
304 fb.list_state.select(Some((cur + 1) % total));
305 }
306 KeyCode::Left => {
307 fb.go_up();
308 }
309 KeyCode::Enter if key.modifiers.contains(KeyModifiers::CONTROL) => {
310 let chosen = fb.current_path.clone();
312 self.confirm_file_browser(chosen, tx);
313 }
314 KeyCode::Right | KeyCode::Enter => {
315 if let Some(idx) = fb.list_state.selected() {
316 if fb.has_parent && idx == 0 {
317 fb.go_up();
318 } else if let Some(entry) = fb.entries.get(idx - offset).cloned() {
319 fb.navigate_into(entry);
320 }
321 }
322 }
323 KeyCode::Char('c') if key.modifiers.is_empty() => {
324 let chosen = fb.current_path.clone();
325 self.confirm_file_browser(chosen, tx);
326 }
327 KeyCode::Char(c)
330 if !key
331 .modifiers
332 .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
333 {
334 fb.jump_to_char(c);
335 }
336 _ => {}
337 }
338 return EventState::Consumed;
339 }
340
341 Overlay::ConfirmFullReindex { focused_button } => {
342 let InputEvent::Key(key) = event else {
343 return EventState::NotConsumed;
344 };
345 match key.code {
346 KeyCode::Esc => {
347 self.overlay = Overlay::None;
348 }
349 KeyCode::Left | KeyCode::Char('h') => {
350 *focused_button = ConfirmButton::Cancel;
351 }
352 KeyCode::Right | KeyCode::Char('l') => {
353 *focused_button = ConfirmButton::Confirm;
354 }
355 KeyCode::Enter => {
356 if *focused_button == ConfirmButton::Confirm {
357 let s = self.settings.read().unwrap();
358 let Some(workspace) = s.resolve_workspace_path() else {
359 drop(s);
360 self.overlay = Overlay::None;
361 return EventState::Consumed;
362 };
363 let workspace_name = s
364 .workspace_config
365 .as_ref()
366 .map(|wc| wc.global.current_workspace.clone())
367 .filter(|n| !n.is_empty());
368 let cache_path = workspace_name.as_ref().map(|n| s.index_for(n));
369 drop(s);
370 let tx2 = tx.clone();
371 let handle = tokio::spawn(async move {
372 let mut config = VaultConfig::new(workspace);
373 if let Some(path) = cache_path {
374 config = config.with_index(path);
375 }
376 let event = match NoteVault::new(config).await {
377 Err(e) => AppEvent::IndexingDone(Err(e.to_string())),
378 Ok(vault) => {
379 let result = vault.recreate_index().await;
380 vault.close().await;
383 match result {
384 Ok(r) => AppEvent::IndexingDone(Ok(r.duration)),
385 Err(e @ VaultError::CaseConflict { .. }) => {
386 AppEvent::VaultConflict(e.to_string())
387 }
388 Err(e) => AppEvent::IndexingDone(Err(e.to_string())),
389 }
390 }
391 };
392 tx2.send(event).ok();
393 });
394 self.overlay = Overlay::IndexingProgress(spawn_running(handle, tx));
395 } else {
396 self.overlay = Overlay::None;
397 }
398 }
399 _ => {}
400 }
401 return EventState::Consumed;
402 }
403
404 Overlay::ConfirmSave { focused_button } => {
405 let InputEvent::Key(key) = event else {
406 return EventState::NotConsumed;
407 };
408 match key.code {
409 KeyCode::Esc => {
410 self.overlay = Overlay::None;
411 }
412 KeyCode::Left | KeyCode::Char('h') => {
413 *focused_button = SaveButton::Save;
414 }
415 KeyCode::Right | KeyCode::Char('l') => {
416 *focused_button = SaveButton::Discard;
417 }
418 KeyCode::Enter => {
419 if *focused_button == SaveButton::Save {
420 self.overlay = Overlay::None;
421 self.do_save(tx);
422 } else {
423 *self.settings.write().unwrap() = self.initial_settings.clone();
428 tx.send(AppEvent::ClosePreferences).ok();
429 }
430 }
431 _ => {}
432 }
433 return EventState::Consumed;
434 }
435
436 Overlay::IndexingProgress(state) => {
437 match state {
438 IndexingProgressState::Running { .. } => {
439 return EventState::Consumed; }
441 IndexingProgressState::Done(_) | IndexingProgressState::Failed(_) => {
442 let InputEvent::Key(key) = event else {
443 return EventState::NotConsumed;
444 };
445 if key.code == KeyCode::Enter || key.code == KeyCode::Esc {
446 self.overlay = Overlay::None;
447 }
448 return EventState::Consumed;
449 }
450 }
451 }
452
453 Overlay::VaultConflict(_) => {
454 let InputEvent::Key(key) = event else {
455 return EventState::NotConsumed;
456 };
457 if key.code == KeyCode::Enter || key.code == KeyCode::Esc {
458 self.overlay = Overlay::None;
459 }
460 return EventState::Consumed;
461 }
462 }
463
464 let InputEvent::Key(key) = event else {
466 return EventState::NotConsumed;
467 };
468 match key.code {
469 KeyCode::Esc => {
470 let changed = *self.settings.read().unwrap() != self.initial_settings;
471 if !changed {
472 tx.send(AppEvent::ClosePreferences).ok();
473 } else {
474 self.overlay = Overlay::ConfirmSave {
475 focused_button: SaveButton::Save,
476 };
477 }
478 EventState::Consumed
479 }
480 KeyCode::Tab => {
481 self.focus = match self.focus {
482 PreferencesFocus::Sidebar => PreferencesFocus::Content,
483 PreferencesFocus::Content => PreferencesFocus::Sidebar,
484 };
485 EventState::Consumed
486 }
487 _ => match self.focus {
488 PreferencesFocus::Sidebar => match key.code {
489 KeyCode::Down | KeyCode::Char('j') => {
490 self.section = match self.section {
491 PreferencesSection::Workspaces => PreferencesSection::Appearance,
492 PreferencesSection::Appearance => PreferencesSection::Display,
493 PreferencesSection::Display => PreferencesSection::Sorting,
494 PreferencesSection::Sorting => PreferencesSection::Indexing,
495 PreferencesSection::Indexing => PreferencesSection::Editor,
496 PreferencesSection::Editor => PreferencesSection::Server,
497 PreferencesSection::Server => PreferencesSection::Workspaces,
498 };
499 EventState::Consumed
500 }
501 KeyCode::Up | KeyCode::Char('k') => {
502 self.section = match self.section {
503 PreferencesSection::Workspaces => PreferencesSection::Server,
504 PreferencesSection::Appearance => PreferencesSection::Workspaces,
505 PreferencesSection::Display => PreferencesSection::Appearance,
506 PreferencesSection::Sorting => PreferencesSection::Display,
507 PreferencesSection::Indexing => PreferencesSection::Sorting,
508 PreferencesSection::Editor => PreferencesSection::Indexing,
509 PreferencesSection::Server => PreferencesSection::Editor,
510 };
511 EventState::Consumed
512 }
513 KeyCode::Enter => {
514 self.focus = PreferencesFocus::Content;
515 EventState::Consumed
516 }
517 _ => EventState::NotConsumed,
518 },
519 PreferencesFocus::Content => {
520 let app_event = InputEvent::Key(*key);
521 match self.section {
522 PreferencesSection::Appearance => {
523 let r = self.appearance_section.handle_input(&app_event, tx);
524 if r.is_consumed() {
529 let theme = self.appearance_section.selected_theme().clone();
530 self.settings.write().unwrap().set_theme(theme.name.clone());
531 self.theme = theme.adapt_to_terminal();
532 }
533 r
534 }
535 PreferencesSection::Display => {
536 let r = self.display_section.handle_input(&app_event, tx);
537 {
538 let mut s = self.settings.write().unwrap();
539 s.use_nerd_fonts = self.display_section.use_nerd_fonts;
540 let global = &mut s
541 .workspace_config
542 .get_or_insert_with(
543 crate::settings::workspace_config::WorkspaceConfig::new_empty,
544 )
545 .global;
546 global.update_check = self.display_section.update_check;
547 global.mouse = self.display_section.mouse;
548 s.leader_timeout_ms = self.display_section.leader_timeout_ms;
549 }
550 r
551 }
552 PreferencesSection::Sorting => {
553 let r = self.sorting_section.handle_input(&app_event, tx);
554 {
555 let mut s = self.settings.write().unwrap();
556 s.default_sort_field = self.sorting_section.default_sort_field;
557 s.default_sort_order = self.sorting_section.default_sort_order;
558 s.journal_sort_field = self.sorting_section.journal_sort_field;
559 s.journal_sort_order = self.sorting_section.journal_sort_order;
560 }
561 r
562 }
563 PreferencesSection::Workspaces => {
564 let pre_mode = self.workspaces_section.mode().clone();
566 let pre_selected = self
567 .workspaces_section
568 .selected_name()
569 .map(|s| s.to_string());
570
571 let r = self.workspaces_section.handle_input(&app_event, tx);
572
573 let post_mode = self.workspaces_section.mode().clone();
574
575 if pre_mode == WorkspaceMode::Creating
579 && post_mode == WorkspaceMode::Creating
580 && key.code == KeyCode::Enter
581 {
582 let name = self.workspaces_section.input().trim().to_string();
583 if !name.is_empty() {
584 let exists = self
586 .settings
587 .read()
588 .unwrap()
589 .workspace_config
590 .as_ref()
591 .is_some_and(|wc| wc.workspaces.contains_key(&name));
592 if exists {
593 self.workspaces_section.set_error(format!(
594 "Workspace '{}' already exists.",
595 name
596 ));
597 } else {
598 self.pending_create_name = Some(name);
599 self.workspaces_section.reset_mode();
600 }
601 }
602 }
603
604 if pre_mode == WorkspaceMode::Renaming
606 && post_mode == WorkspaceMode::Renaming
607 && key.code == KeyCode::Enter
608 {
609 let new_name = self.workspaces_section.input().trim().to_string();
610 let duplicate = !new_name.is_empty()
612 && pre_selected.as_deref() != Some(&new_name)
613 && self
614 .settings
615 .read()
616 .unwrap()
617 .workspace_config
618 .as_ref()
619 .is_some_and(|wc| wc.workspaces.contains_key(&new_name));
620 if duplicate {
621 self.workspaces_section.set_error(format!(
622 "Workspace '{}' already exists.",
623 new_name
624 ));
625 } else if let Some(old_name) = pre_selected.as_deref()
626 && !new_name.is_empty()
627 && new_name != old_name
628 {
629 let mut s = self.settings.write().unwrap();
630 if let Some(ref mut wc) = s.workspace_config {
631 wc.rename_workspace(old_name, new_name.clone());
639 }
640 }
641 self.workspaces_section.reset_mode();
642 self.workspaces_section
643 .refresh(&self.settings.read().unwrap());
644 }
645
646 if pre_mode == WorkspaceMode::ConfirmDelete
648 && post_mode == WorkspaceMode::ConfirmDelete
649 && key.code == KeyCode::Char('y')
650 {
651 if let Some(name) = pre_selected.as_deref() {
652 let artifacts = {
661 let mut s = self.settings.write().unwrap();
662 let removable =
663 s.workspace_config.as_ref().is_some_and(|wc| {
664 name != wc.global.current_workspace
665 && wc.workspaces.contains_key(name)
666 });
667 removable.then(|| {
668 let artifacts = s.workspace_artifacts(name);
669 if let Some(ref mut wc) = s.workspace_config {
670 wc.workspaces.remove(name);
671 }
672 artifacts
673 })
674 };
675 if let Some((index, history)) = artifacts {
676 let leftovers =
677 crate::settings::delete_artifacts(&index, &history);
678 if !leftovers.is_empty() {
679 tracing::warn!(
680 "workspace '{name}' removed, but these files \
681 stayed behind:\n{}",
682 leftovers.join("\n")
683 );
684 }
685 }
686 }
687 self.workspaces_section.reset_mode();
688 self.workspaces_section
689 .refresh(&self.settings.read().unwrap());
690 }
691
692 r
693 }
694 PreferencesSection::Indexing => {
695 self.indexing_section.handle_input(&app_event, tx)
696 }
697 PreferencesSection::Editor => {
698 let r = self.editor_section.handle_input(&app_event, tx);
699 let mut s = self.settings.write().unwrap();
700 s.autosave_interval_secs = self.editor_section.autosave_interval_secs;
701 s.editor_backend = self.editor_section.editor_backend;
702 r
703 }
704 PreferencesSection::Server => {
705 let r = self.server_section.handle_input(&app_event, tx);
706 if r.is_consumed() {
707 let mut s = self.settings.write().unwrap();
708 s.workspace_config
709 .get_or_insert_with(
710 crate::settings::workspace_config::WorkspaceConfig::new_empty,
711 )
712 .global
713 .kimun_server_url = self.server_section.server_url.clone();
714 }
715 r
716 }
717 }
718 }
719 },
720 }
721 }
722
723 async fn handle_app_message(&mut self, msg: AppEvent, tx: &AppTx) {
724 match msg {
725 AppEvent::OpenFileBrowser => {
726 let starting_dir = self
727 .settings
728 .read()
729 .unwrap()
730 .resolve_workspace_path()
731 .or_else(|| kimun_core::system::browse_root().ok())
732 .map(|p| p.into_path_buf())
733 .unwrap_or_else(|| PathBuf::from("/"));
734 self.overlay = Overlay::FileBrowser(FileBrowserState::load(starting_dir));
735 }
736 AppEvent::TriggerFastReindex => {
737 let s = self.settings.read().unwrap();
740 let Some(workspace) = s.resolve_workspace_path() else {
741 drop(s);
742 tx.send(AppEvent::IndexingDone(Err("No workspace set".to_string())))
743 .ok();
744 return;
745 };
746 let workspace_name = s
747 .workspace_config
748 .as_ref()
749 .map(|wc| wc.global.current_workspace.clone())
750 .filter(|n| !n.is_empty());
751 let cache_path = workspace_name.as_ref().map(|n| s.index_for(n));
752 drop(s);
753 let tx2 = tx.clone();
754 let handle = tokio::spawn(async move {
755 let result = async {
756 let mut config = VaultConfig::new(workspace);
757 if let Some(path) = cache_path {
758 config = config.with_index(path);
759 }
760 let vault = NoteVault::new(config).await.map_err(|e| e.to_string())?;
761 let result = vault.index_notes(NotesValidation::Fast).await;
762 vault.close().await;
765 result.map_err(|e| e.to_string()).map(|r| r.duration)
766 }
767 .await;
768 tx2.send(AppEvent::IndexingDone(result)).ok();
769 });
770 self.overlay = Overlay::IndexingProgress(spawn_running(handle, tx));
771 }
772 AppEvent::TriggerFullReindex => {
773 self.overlay = Overlay::ConfirmFullReindex {
774 focused_button: ConfirmButton::Cancel,
775 };
776 }
777 AppEvent::IndexingDone(result) => match result {
778 Ok(duration) => {
779 self.settings.write().unwrap().report_indexed();
780 if self.pending_save_after_index {
781 self.pending_save_after_index = false;
782 self.settings.read().unwrap().save_to_disk().ok();
783 tx.send(AppEvent::PreferencesSaved).ok();
784 } else {
785 self.overlay =
786 Overlay::IndexingProgress(IndexingProgressState::Done(duration));
787 }
788 }
789 Err(msg) => {
790 self.pending_save_after_index = false;
791 self.overlay = Overlay::IndexingProgress(IndexingProgressState::Failed(msg));
792 }
793 },
794 _ => {}
795 }
796 }
797
798 fn render(&mut self, f: &mut Frame) {
799 let theme = self.theme.clone();
800 f.render_widget(Block::default().style(theme.base_style()), f.area());
801
802 let rows = Layout::default()
803 .direction(Direction::Vertical)
804 .constraints([
805 Constraint::Length(3),
806 Constraint::Min(0),
807 Constraint::Length(1),
808 ])
809 .split(f.area());
810
811 let header = Block::default()
812 .title("Preferences")
813 .borders(Borders::ALL)
814 .border_style(Style::default().fg(theme.border_dim.to_ratatui()))
815 .style(theme.base_style())
816 .title_style(Style::default().fg(theme.accent.to_ratatui()));
817 f.render_widget(header, rows[0]);
818
819 f.render_widget(
821 Paragraph::new(" [Esc] Save & Close [Tab] Switch sidebar/content").style(
822 Style::default()
823 .fg(theme.gray.to_ratatui())
824 .bg(theme.bg.to_ratatui()),
825 ),
826 rows[2],
827 );
828
829 let cols = Layout::default()
830 .direction(Direction::Horizontal)
831 .constraints([Constraint::Length(20), Constraint::Min(0)])
832 .split(rows[1]);
833
834 let sidebar_focused = self.focus == PreferencesFocus::Sidebar;
836 let active_idx = match self.section {
837 PreferencesSection::Workspaces => 0,
838 PreferencesSection::Appearance => 1,
839 PreferencesSection::Display => 2,
840 PreferencesSection::Sorting => 3,
841 PreferencesSection::Indexing => 4,
842 PreferencesSection::Editor => 5,
843 PreferencesSection::Server => 6,
844 };
845 let items: Vec<ListItem> = [
846 "Workspaces",
847 "Appearance",
848 "Display",
849 "Sorting",
850 "Indexing",
851 "Editor",
852 "Server",
853 ]
854 .iter()
855 .enumerate()
856 .map(|(i, name)| {
857 let prefix = if i == active_idx { "> " } else { " " };
858 let fg = if i == active_idx {
859 theme.accent.to_ratatui()
860 } else {
861 theme.fg.to_ratatui()
862 };
863 ListItem::new(format!("{}{}", prefix, name))
864 .style(Style::default().fg(fg).bg(theme.bg_panel.to_ratatui()))
865 })
866 .collect();
867 let sidebar_block = Block::default()
868 .borders(Borders::ALL)
869 .border_style(theme.border_style(sidebar_focused))
870 .style(theme.panel_style());
871 let sidebar_list = List::new(items).block(sidebar_block);
872 f.render_widget(sidebar_list, cols[0]);
873
874 let content_focused = self.focus == PreferencesFocus::Content;
876 match self.section {
877 PreferencesSection::Appearance => {
878 self.appearance_section
879 .render(f, cols[1], &theme, content_focused)
880 }
881 PreferencesSection::Display => {
882 self.display_section
883 .render(f, cols[1], &theme, content_focused)
884 }
885 PreferencesSection::Sorting => {
886 self.sorting_section
887 .render(f, cols[1], &theme, content_focused)
888 }
889 PreferencesSection::Workspaces => {
890 self.workspaces_section
891 .render(f, cols[1], &theme, content_focused)
892 }
893 PreferencesSection::Indexing => {
894 self.indexing_section
895 .render(f, cols[1], &theme, content_focused)
896 }
897 PreferencesSection::Editor => {
898 self.editor_section
899 .render(f, cols[1], &theme, content_focused)
900 }
901 PreferencesSection::Server => {
902 self.server_section
903 .render(f, cols[1], &theme, content_focused)
904 }
905 }
906
907 self.render_overlay(f, &theme);
908 }
909}
910
911impl PreferencesScreen {
912 fn render_overlay(&mut self, f: &mut Frame, theme: &Theme) {
913 match &mut self.overlay {
914 Overlay::None => {}
915
916 Overlay::FileBrowser(fb) => {
917 let area = crate::components::centered_rect(60, 80, f.area());
918 f.render_widget(Clear, area);
919 let block = Block::default()
920 .title("Select Vault Directory")
921 .borders(Borders::ALL)
922 .border_style(Style::default().fg(theme.accent.to_ratatui()))
923 .style(theme.base_style());
924 let inner = block.inner(area);
925 f.render_widget(block, area);
926
927 let rows = Layout::default()
928 .direction(Direction::Vertical)
929 .constraints([
930 Constraint::Length(1),
931 Constraint::Min(0),
932 Constraint::Length(1),
933 ])
934 .split(inner);
935
936 let path_str = fb.current_path.to_string_lossy().into_owned();
937 f.render_widget(Paragraph::new(path_str).style(theme.base_style()), rows[0]);
938
939 let mut items: Vec<ListItem> = Vec::new();
940 if fb.has_parent {
941 items.push(
942 ListItem::new(" ../").style(
943 Style::default()
944 .fg(theme.fg_secondary.to_ratatui())
945 .bg(theme.bg.to_ratatui()),
946 ),
947 );
948 }
949 for e in &fb.entries {
950 let name = e.file_name().unwrap_or_default().to_string_lossy();
951 items.push(
952 ListItem::new(format!(" {}/", name)).style(
953 Style::default()
954 .fg(theme.fg.to_ratatui())
955 .bg(theme.bg.to_ratatui()),
956 ),
957 );
958 }
959 let list = List::new(items)
960 .highlight_symbol("▶ ")
961 .highlight_style(Style::default().add_modifier(Modifier::BOLD));
962 f.render_stateful_widget(list, rows[1], &mut fb.list_state);
963 f.render_widget(
964 Paragraph::new("Enter: open c: confirm Esc: cancel a-z: jump")
965 .style(theme.base_style()),
966 rows[2],
967 );
968 }
969
970 Overlay::ConfirmFullReindex { focused_button } => {
971 let area = fixed_centered_rect(44, 6, f.area());
972 f.render_widget(Clear, area);
973 let block = Block::default()
974 .title("Full Reindex")
975 .borders(Borders::ALL)
976 .border_style(Style::default().fg(theme.accent.to_ratatui()))
977 .style(theme.base_style());
978 let inner = block.inner(area);
979 f.render_widget(block, area);
980 let cancel = if *focused_button == ConfirmButton::Cancel {
981 "[ Cancel ]"
982 } else {
983 " Cancel "
984 };
985 let confirm = if *focused_button == ConfirmButton::Confirm {
986 "[ Confirm ]"
987 } else {
988 " Confirm "
989 };
990 f.render_widget(
991 Paragraph::new(format!(
992 "\n This may take a while.\n\n {} {}",
993 cancel, confirm
994 ))
995 .style(theme.base_style()),
996 inner,
997 );
998 }
999
1000 Overlay::ConfirmSave { focused_button } => {
1001 let area = fixed_centered_rect(44, 6, f.area());
1002 f.render_widget(Clear, area);
1003 let block = Block::default()
1004 .title("Save Preferences?")
1005 .borders(Borders::ALL)
1006 .border_style(Style::default().fg(theme.accent.to_ratatui()))
1007 .style(theme.base_style());
1008 let inner = block.inner(area);
1009 f.render_widget(block, area);
1010 let save = if *focused_button == SaveButton::Save {
1011 "[ Save ]"
1012 } else {
1013 " Save "
1014 };
1015 let discard = if *focused_button == SaveButton::Discard {
1016 "[ Discard ]"
1017 } else {
1018 " Discard "
1019 };
1020 f.render_widget(
1021 Paragraph::new(format!(
1022 "\n You have unsaved changes.\n\n {} {}",
1023 save, discard
1024 ))
1025 .style(theme.base_style()),
1026 inner,
1027 );
1028 }
1029
1030 Overlay::IndexingProgress(state) => {
1031 render_indexing_overlay(
1032 f,
1033 state,
1034 &mut self.throbber_state,
1035 theme,
1036 "Reindex in progress…",
1037 );
1038 }
1039
1040 Overlay::VaultConflict(msg) => {
1041 let area = fixed_centered_rect(60, 9, f.area());
1042 f.render_widget(Clear, area);
1043 let block = Block::default()
1044 .title("Vault Error")
1045 .borders(Borders::ALL)
1046 .border_style(Style::default().fg(theme.accent.to_ratatui()))
1047 .style(theme.base_style());
1048 let inner = block.inner(area);
1049 f.render_widget(block, area);
1050 f.render_widget(
1051 Paragraph::new(format!("\n {}\n\n [ OK ]", msg))
1052 .style(theme.base_style())
1053 .wrap(Wrap { trim: false }),
1054 inner,
1055 );
1056 }
1057 }
1058 }
1059}
1060
1061#[cfg(test)]
1062mod file_browser_tests {
1063 use super::*;
1064 use std::fs;
1065
1066 fn make_temp_dir(name: &str) -> PathBuf {
1067 let path = std::env::temp_dir().join(format!("kimun_test_{}", name));
1068 let _ = fs::remove_dir_all(&path);
1069 fs::create_dir_all(&path).unwrap();
1070 path
1071 }
1072
1073 #[test]
1074 fn load_returns_only_directories() {
1075 let root = make_temp_dir("fb_only_dirs");
1076 fs::create_dir(root.join("alpha")).unwrap();
1077 fs::create_dir(root.join("beta")).unwrap();
1078 fs::write(root.join("note.md"), b"text").unwrap();
1079 let state = FileBrowserState::load(root.clone());
1080 assert_eq!(state.entries.len(), 2);
1081 assert!(state.entries.iter().all(|e| e.is_dir()));
1082 }
1083
1084 #[test]
1085 fn load_sorts_alphabetically() {
1086 let root = make_temp_dir("fb_sorted");
1087 fs::create_dir(root.join("zebra")).unwrap();
1088 fs::create_dir(root.join("alpha")).unwrap();
1089 fs::create_dir(root.join("mango")).unwrap();
1090 let state = FileBrowserState::load(root.clone());
1091 let names: Vec<_> = state
1092 .entries
1093 .iter()
1094 .map(|e| e.file_name().unwrap().to_str().unwrap().to_string())
1095 .collect();
1096 assert_eq!(names, vec!["alpha", "mango", "zebra"]);
1097 }
1098
1099 #[test]
1100 fn load_handles_empty_directory() {
1101 let root = make_temp_dir("fb_empty");
1102 let state = FileBrowserState::load(root.clone());
1103 assert_eq!(state.current_path, root);
1104 assert!(state.entries.is_empty());
1105 assert!(state.has_parent);
1107 assert_eq!(state.list_state.selected(), Some(0));
1108 }
1109
1110 #[test]
1111 fn load_root_has_no_parent_entry() {
1112 let state = FileBrowserState::load(PathBuf::from("/"));
1113 assert!(!state.has_parent);
1114 if state.entries.is_empty() {
1116 assert_eq!(state.list_state.selected(), None);
1117 } else {
1118 assert_eq!(state.list_state.selected(), Some(0));
1119 }
1120 }
1121
1122 #[test]
1123 fn navigate_into_updates_path_and_reloads() {
1124 let root = make_temp_dir("fb_nav");
1125 let sub = root.join("sub");
1126 fs::create_dir_all(&sub).unwrap();
1127 fs::create_dir(sub.join("child")).unwrap();
1128 let mut state = FileBrowserState::load(root.clone());
1129 state.navigate_into(sub.clone());
1130 assert_eq!(state.current_path, sub);
1131 assert_eq!(state.entries.len(), 1);
1132 assert_eq!(state.entries[0].file_name().unwrap(), "child");
1133 }
1134
1135 #[test]
1136 fn go_up_updates_to_parent() {
1137 let root = make_temp_dir("fb_go_up");
1138 let sub = root.join("sub");
1139 fs::create_dir_all(&sub).unwrap();
1140 let mut state = FileBrowserState::load(sub.clone());
1141 state.go_up();
1142 assert_eq!(state.current_path, root);
1143 }
1144}
1145
1146#[cfg(test)]
1147mod settings_screen_tests {
1148 use std::sync::{Arc, RwLock};
1149 use std::time::Duration;
1150
1151 use super::*;
1152 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
1153 use tokio::sync::mpsc::unbounded_channel;
1154
1155 fn key(code: KeyCode) -> InputEvent {
1156 InputEvent::Key(KeyEvent {
1157 code,
1158 modifiers: KeyModifiers::NONE,
1159 kind: KeyEventKind::Press,
1160 state: KeyEventState::NONE,
1161 })
1162 }
1163
1164 fn shared_defaults() -> SharedSettings {
1165 Arc::new(RwLock::new(AppSettings::default()))
1166 }
1167
1168 fn abs(unix_style: &str) -> PathBuf {
1176 if cfg!(windows) {
1177 PathBuf::from(format!(
1178 "C:\\{}",
1179 unix_style.trim_start_matches('/').replace('/', "\\")
1180 ))
1181 } else {
1182 PathBuf::from(unix_style)
1183 }
1184 }
1185
1186 fn make_screen() -> PreferencesScreen {
1187 PreferencesScreen::new(shared_defaults())
1188 }
1189
1190 #[test]
1191 fn esc_sends_close_settings_when_no_changes() {
1192 let (tx, mut rx) = unbounded_channel();
1193 let mut screen = make_screen();
1194 screen.handle_input(&key(KeyCode::Esc), &tx);
1195 let msg = rx.try_recv().expect("expected message");
1196 assert!(matches!(msg, AppEvent::ClosePreferences));
1197 }
1198
1199 #[test]
1200 fn esc_shows_confirm_save_when_settings_changed() {
1201 let (tx, mut rx) = unbounded_channel();
1202 let mut screen = make_screen();
1203 screen
1204 .settings
1205 .write()
1206 .unwrap()
1207 .set_theme("Gruvbox Light".to_string());
1208 screen.handle_input(&key(KeyCode::Esc), &tx);
1209 assert!(rx.try_recv().is_err(), "no message should be sent yet");
1210 assert!(matches!(screen.overlay, Overlay::ConfirmSave { .. }));
1211 }
1212
1213 #[test]
1214 fn confirm_save_discard_sends_close_settings() {
1215 let (tx, mut rx) = unbounded_channel();
1216 let mut screen = make_screen();
1217 screen
1218 .settings
1219 .write()
1220 .unwrap()
1221 .set_theme("Gruvbox Light".to_string());
1222 screen.overlay = Overlay::ConfirmSave {
1223 focused_button: SaveButton::Discard,
1224 };
1225 screen.handle_input(&key(KeyCode::Enter), &tx);
1226 let msg = rx.try_recv().expect("expected message");
1227 assert!(matches!(msg, AppEvent::ClosePreferences));
1228 }
1229
1230 #[test]
1231 fn confirm_save_save_vault_unchanged_sends_settings_saved() {
1232 let (tx, mut rx) = unbounded_channel();
1233 let mut screen = make_screen();
1234 screen
1235 .settings
1236 .write()
1237 .unwrap()
1238 .set_theme("Gruvbox Light".to_string());
1239 screen.overlay = Overlay::ConfirmSave {
1240 focused_button: SaveButton::Save,
1241 };
1242 screen.handle_input(&key(KeyCode::Enter), &tx);
1243 let msg = rx.try_recv().expect("expected message");
1244 assert!(matches!(msg, AppEvent::PreferencesSaved));
1245 assert!(rx.try_recv().is_err());
1246 }
1247
1248 #[tokio::test]
1249 async fn confirm_save_vault_changed_sets_pending_and_shows_progress() {
1250 let (tx, _rx) = unbounded_channel();
1251 let mut settings = AppSettings::default();
1252 let mut wc = crate::settings::workspace_config::WorkspaceConfig::new_empty();
1253 wc.add_workspace("main".to_string(), abs("/original/path"))
1254 .unwrap();
1255 settings.workspace_config = Some(wc);
1256 let shared = Arc::new(RwLock::new(settings));
1257 let mut screen = PreferencesScreen::new(shared);
1258 screen
1259 .settings
1260 .write()
1261 .unwrap()
1262 .set_workspace_path("main", abs("/new/path"));
1263 screen.overlay = Overlay::ConfirmSave {
1264 focused_button: SaveButton::Save,
1265 };
1266 screen.handle_input(&key(KeyCode::Enter), &tx);
1267 assert!(screen.pending_save_after_index);
1268 assert!(matches!(
1269 screen.overlay,
1270 Overlay::IndexingProgress(IndexingProgressState::Running { .. })
1271 ));
1272 }
1273
1274 #[tokio::test]
1275 async fn indexing_done_ok_with_pending_auto_closes() {
1276 let (tx, mut rx) = unbounded_channel();
1277 let mut screen = make_screen();
1278 screen.pending_save_after_index = true;
1279 screen.overlay = Overlay::IndexingProgress(IndexingProgressState::Running {
1280 work: tokio::spawn(async {}),
1281 ticker: tokio::spawn(async {}),
1282 });
1283 screen
1284 .handle_app_message(AppEvent::IndexingDone(Ok(Duration::from_secs(1))), &tx)
1285 .await;
1286 let msg = rx.try_recv().expect("expected PreferencesSaved");
1287 assert!(matches!(msg, AppEvent::PreferencesSaved));
1288 assert!(!screen.pending_save_after_index);
1289 }
1290
1291 #[tokio::test]
1292 async fn indexing_done_err_with_pending_shows_failed_no_save() {
1293 let (tx, mut rx) = unbounded_channel();
1294 let mut screen = make_screen();
1295 screen.pending_save_after_index = true;
1296 screen.overlay = Overlay::IndexingProgress(IndexingProgressState::Running {
1297 work: tokio::spawn(async {}),
1298 ticker: tokio::spawn(async {}),
1299 });
1300 screen
1301 .handle_app_message(AppEvent::IndexingDone(Err("disk error".to_string())), &tx)
1302 .await;
1303 assert!(
1304 rx.try_recv().is_err(),
1305 "no PreferencesSaved when index failed"
1306 );
1307 assert!(!screen.pending_save_after_index);
1308 assert!(matches!(
1309 screen.overlay,
1310 Overlay::IndexingProgress(IndexingProgressState::Failed(_))
1311 ));
1312 }
1313
1314 #[tokio::test]
1315 async fn indexing_done_ok_without_pending_shows_done() {
1316 let (tx, mut rx) = unbounded_channel();
1317 let mut screen = make_screen();
1318 screen.pending_save_after_index = false;
1319 screen.overlay = Overlay::IndexingProgress(IndexingProgressState::Running {
1320 work: tokio::spawn(async {}),
1321 ticker: tokio::spawn(async {}),
1322 });
1323 screen
1324 .handle_app_message(AppEvent::IndexingDone(Ok(Duration::from_secs(2))), &tx)
1325 .await;
1326 assert!(
1327 rx.try_recv().is_err(),
1328 "no auto-close when pending is false"
1329 );
1330 assert!(matches!(
1331 screen.overlay,
1332 Overlay::IndexingProgress(IndexingProgressState::Done(_))
1333 ));
1334 }
1335
1336 #[test]
1337 fn esc_blocked_while_indexing_running() {
1338 let rt = tokio::runtime::Runtime::new().unwrap();
1339 let (tx, mut rx) = unbounded_channel();
1340 let mut screen = make_screen();
1341 screen.overlay = Overlay::IndexingProgress(IndexingProgressState::Running {
1342 work: rt.spawn(async {}),
1343 ticker: rt.spawn(async {}),
1344 });
1345 screen.handle_input(&key(KeyCode::Esc), &tx);
1346 assert!(rx.try_recv().is_err(), "Esc must be blocked while indexing");
1347 }
1348
1349 #[tokio::test]
1350 async fn confirm_full_reindex_esc_closes_overlay() {
1351 let (tx, _rx) = unbounded_channel();
1352 let mut screen = make_screen();
1353 screen.overlay = Overlay::ConfirmFullReindex {
1354 focused_button: ConfirmButton::Cancel,
1355 };
1356 screen.handle_input(&key(KeyCode::Esc), &tx);
1357 assert!(matches!(screen.overlay, Overlay::None));
1358 }
1359
1360 #[test]
1361 fn new_with_error_sets_vault_conflict_overlay_with_message() {
1362 let screen =
1363 PreferencesScreen::new_with_error(shared_defaults(), "test error msg".to_string());
1364 match screen.overlay {
1365 Overlay::VaultConflict(ref msg) => {
1366 assert_eq!(msg, "test error msg");
1367 }
1368 _ => panic!("expected Overlay::VaultConflict(...)"),
1369 }
1370 }
1371}