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