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, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind};
8use ratatui::layout::{Constraint, Direction, Layout, Position, Rect};
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::button_row::ButtonRow;
16use crate::components::clickable::{is_press_outside, list_index_at};
17use crate::components::event_state::EventState;
18use crate::components::events::{AppEvent, AppTx, InputEvent};
19use crate::components::hint_row::HintRow;
20use crate::components::indexing::{
21 IndexingProgressState, fixed_centered_rect, render_indexing_overlay, spawn_running,
22};
23use crate::components::preferences::appearance_section::AppearanceSection;
24use crate::components::preferences::display_section::DisplaySection;
25use crate::components::preferences::editor_section::EditorSection;
26use crate::components::preferences::indexing_section::IndexingSection;
27use crate::components::preferences::server_section::ServerSection;
28use crate::components::preferences::sorting_section::SortingSection;
29use crate::components::preferences::workspaces_section::{
30 Mode as WorkspaceMode, WorkspacesSection,
31};
32use crate::settings::AppSettings;
33use crate::settings::SharedSettings;
34use crate::settings::config_migration::CURRENT_CONFIG_VERSION;
35use crate::settings::themes::Theme;
36
37use crate::components::dir_browser::FileBrowserState;
38
39#[derive(Debug, PartialEq)]
42pub enum ConfirmButton {
43 Cancel,
44 Confirm,
45}
46
47#[derive(Debug, PartialEq)]
48pub enum SaveButton {
49 Save,
50 Discard,
51}
52
53pub enum Overlay {
54 None,
55 FileBrowser(FileBrowserState),
56 ConfirmFullReindex {
57 focused_button: ConfirmButton,
58 },
59 ConfirmSave {
60 focused_button: SaveButton,
61 },
62 IndexingProgress(IndexingProgressState),
63 VaultConflict(String),
66}
67
68#[derive(Debug, Clone, Copy, PartialEq)]
71enum PreferencesSection {
72 Workspaces,
73 Appearance,
74 Display,
75 Sorting,
76 Indexing,
77 Editor,
78 Server,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq)]
82enum PreferencesFocus {
83 Sidebar,
84 Content,
85}
86
87pub struct PreferencesScreen {
90 pub settings: SharedSettings,
91 pub initial_settings: AppSettings,
92 pub theme: Theme,
93 section: PreferencesSection,
94 focus: PreferencesFocus,
95 appearance_section: AppearanceSection,
96 display_section: DisplaySection,
97 sorting_section: SortingSection,
98 workspaces_section: WorkspacesSection,
99 pending_create_name: Option<String>,
100 indexing_section: IndexingSection,
101 editor_section: EditorSection,
102 server_section: ServerSection,
103 pub overlay: Overlay,
104 pub pending_save_after_index: bool,
105 throbber_state: ThrobberState,
106 pointer: Option<Position>,
108 sidebar_rows: Rect,
110 content_rect: Rect,
112 footer: HintRow,
114 overlay_rect: Rect,
117 open_guard: crate::components::clickable::OpenGuard,
122 shown_overlay: Option<std::mem::Discriminant<Overlay>>,
123 browser_rows: Rect,
125 browser_hints: HintRow,
126 reindex_buttons: ButtonRow,
128 save_buttons: ButtonRow,
130}
131
132impl PreferencesScreen {
133 pub fn new(settings: SharedSettings) -> Self {
134 let s = settings.read().unwrap();
135 let themes = s.theme_list();
138 let theme = themes
139 .iter()
140 .find(|t| t.name == s.theme)
141 .cloned()
142 .unwrap_or_default()
143 .adapt_to_terminal();
144 let active_name = theme.name.clone();
145 let vault_available = s
146 .workspace_config
147 .as_ref()
148 .is_some_and(|wc| wc.get_current_workspace().is_some());
149 let autosave_interval_secs = s.autosave_interval_secs;
150 let editor_backend = s.editor_backend;
151 let use_nerd_fonts = s.use_nerd_fonts;
152 let update_check = s.update_check();
153 let mouse = s.mouse();
154 let leader_timeout_ms = s.leader_timeout_ms;
155 let server_url = s
156 .workspace_config
157 .as_ref()
158 .and_then(|wc| wc.global.kimun_server_url.clone());
159 let initial_settings = s.clone();
160 let workspaces_section = WorkspacesSection::new(&s);
161 let sorting_section = SortingSection::new(
162 s.default_sort_field,
163 s.default_sort_order,
164 s.journal_sort_field,
165 s.journal_sort_order,
166 );
167 drop(s);
168 Self {
169 appearance_section: AppearanceSection::new(themes, &active_name),
170 display_section: DisplaySection::new(
171 use_nerd_fonts,
172 update_check,
173 mouse,
174 leader_timeout_ms,
175 ),
176 sorting_section,
177 workspaces_section,
178 pending_create_name: None,
179 indexing_section: IndexingSection::new(vault_available),
180 editor_section: EditorSection::new(autosave_interval_secs, editor_backend),
181 server_section: ServerSection::new(server_url),
182 settings,
183 initial_settings,
184 theme,
185 section: PreferencesSection::Workspaces,
186 focus: PreferencesFocus::Sidebar,
187 overlay: Overlay::None,
188 pending_save_after_index: false,
189 throbber_state: ThrobberState::default(),
190 pointer: None,
191 sidebar_rows: Rect::default(),
192 content_rect: Rect::default(),
193 footer: HintRow::new(&[
194 (KeyCode::Esc, "Esc", "Save & Close"),
195 (KeyCode::Tab, "Tab", "Switch sidebar/content"),
196 ]),
197 overlay_rect: Rect::default(),
198 open_guard: Default::default(),
199 shown_overlay: None,
200 browser_rows: Rect::default(),
201 browser_hints: HintRow::new(&[
202 (KeyCode::Enter, "⏎", "Open"),
203 (KeyCode::Char('c'), "c", "Choose this folder"),
204 (KeyCode::Esc, "Esc", "Cancel"),
205 (KeyCode::Null, "a-z", "Jump"),
206 ])
207 .with_indent(0),
208 reindex_buttons: ButtonRow::new(&["Cancel", "Confirm"]),
209 save_buttons: ButtonRow::new(&["Save", "Discard"]),
210 }
211 }
212
213 pub fn new_with_error(settings: SharedSettings, error: String) -> Self {
220 let mut s = Self::new(settings);
221 s.overlay = Overlay::VaultConflict(error);
222 s
223 }
224
225 fn do_save(&mut self, tx: &AppTx) {
226 let s = self.settings.read().unwrap();
227 let current_path = s.resolve_workspace_path();
228 let initial_path = self.initial_settings.resolve_workspace_path();
229 if current_path != initial_path {
230 let Some(workspace) = current_path else {
231 drop(s);
232 tx.send(AppEvent::IndexingDone(Err("No workspace set".to_string())))
233 .ok();
234 return;
235 };
236 let workspace_name = s
237 .workspace_config
238 .as_ref()
239 .map(|wc| wc.global.current_workspace.clone())
240 .filter(|n| !n.is_empty());
241 let cache_path = workspace_name.as_ref().map(|n| s.index_for(n));
242 drop(s);
243 self.pending_save_after_index = true;
244 let tx2 = tx.clone();
245 let handle = tokio::spawn(async move {
246 let mut config = VaultConfig::new(workspace);
247 if let Some(path) = cache_path {
248 config = config.with_index(path);
249 }
250 let event = match NoteVault::new(config).await {
251 Err(e) => AppEvent::IndexingDone(Err(e.to_string())),
252 Ok(vault) => {
253 let result = vault.recreate_index().await;
254 vault.close().await;
258 match result {
259 Ok(r) => AppEvent::IndexingDone(Ok(r.duration)),
260 Err(e @ VaultError::CaseConflict { .. }) => {
261 AppEvent::VaultConflict(e.to_string())
262 }
263 Err(e) => AppEvent::IndexingDone(Err(e.to_string())),
264 }
265 }
266 };
267 tx2.send(event).ok();
268 });
269 self.overlay = Overlay::IndexingProgress(spawn_running(handle, tx));
270 } else {
271 s.save_to_disk().ok();
272 drop(s);
273 tx.send(AppEvent::PreferencesSaved).ok();
274 }
275 }
276
277 fn confirm_file_browser(&mut self, chosen: PathBuf, _tx: &AppTx) {
279 use crate::settings::workspace_config::WorkspaceConfig;
280
281 if let Some(name) = self.pending_create_name.take() {
282 let name = name.to_lowercase();
283 {
284 let mut s = self.settings.write().unwrap();
285 if s.workspace_config.is_none() {
286 s.workspace_config = Some(WorkspaceConfig::new_empty());
287 s.config_version = CURRENT_CONFIG_VERSION;
288 }
289 if let Some(ref mut wc) = s.workspace_config
290 && let Err(e) = wc.add_workspace(name.clone(), chosen)
291 {
292 tracing::warn!("rejected workspace add: {}", e);
293 }
294 }
295 self.workspaces_section
296 .refresh(&self.settings.read().unwrap());
297 self.indexing_section.set_vault_available(true);
298 } else {
299 {
301 let mut s = self.settings.write().unwrap();
302 if let Some(name) = self
307 .workspaces_section
308 .selected_name()
309 .map(|s| s.to_string())
310 {
311 s.set_workspace_path(&name, chosen);
312 }
313 }
314 self.workspaces_section
315 .refresh(&self.settings.read().unwrap());
316 self.indexing_section.set_vault_available(true);
317 }
318 self.overlay = Overlay::None;
319 }
320}
321
322impl PreferencesScreen {
323 fn sync_section(&mut self) {
329 use crate::settings::workspace_config::WorkspaceConfig;
330 match self.section {
331 PreferencesSection::Appearance => {
332 let theme = self.appearance_section.selected_theme().clone();
336 if self.settings.read().unwrap().theme != theme.name {
337 self.settings.write().unwrap().set_theme(theme.name.clone());
338 self.theme = theme.adapt_to_terminal();
339 }
340 }
341 PreferencesSection::Display => {
342 let mut s = self.settings.write().unwrap();
343 s.use_nerd_fonts = self.display_section.use_nerd_fonts;
344 let global = &mut s
345 .workspace_config
346 .get_or_insert_with(WorkspaceConfig::new_empty)
347 .global;
348 global.update_check = self.display_section.update_check;
349 global.mouse = self.display_section.mouse;
350 s.leader_timeout_ms = self.display_section.leader_timeout_ms;
351 }
352 PreferencesSection::Sorting => {
353 let mut s = self.settings.write().unwrap();
354 s.default_sort_field = self.sorting_section.default_sort_field;
355 s.default_sort_order = self.sorting_section.default_sort_order;
356 s.journal_sort_field = self.sorting_section.journal_sort_field;
357 s.journal_sort_order = self.sorting_section.journal_sort_order;
358 }
359 PreferencesSection::Editor => {
360 let mut s = self.settings.write().unwrap();
361 s.autosave_interval_secs = self.editor_section.autosave_interval_secs;
362 s.editor_backend = self.editor_section.editor_backend;
363 }
364 PreferencesSection::Server => {
365 self.settings
366 .write()
367 .unwrap()
368 .workspace_config
369 .get_or_insert_with(WorkspaceConfig::new_empty)
370 .global
371 .kimun_server_url = self.server_section.server_url.clone();
372 }
373 PreferencesSection::Workspaces | PreferencesSection::Indexing => {}
374 }
375 }
376
377 fn press(&mut self, key: KeyEvent, tx: &AppTx) -> EventState {
379 self.handle_input(&InputEvent::Key(key), tx)
380 }
381
382 fn handle_mouse(&mut self, m: &MouseEvent, tx: &AppTx) -> EventState {
387 let press = matches!(m.kind, MouseEventKind::Down(_));
388 if !matches!(self.overlay, Overlay::None) {
389 if self
392 .open_guard
393 .swallows(&InputEvent::Mouse(*m), std::time::Instant::now())
394 {
395 return EventState::Consumed;
396 }
397 return self.handle_overlay_mouse(m, tx);
398 }
399 let pos = Position::new(m.column, m.row);
400 if let Some(key) = self.footer.hit(m) {
401 return self.press(key, tx);
402 }
403 if press && self.sidebar_rows.contains(pos) {
405 self.focus = PreferencesFocus::Sidebar;
406 if let Some(idx) = list_index_at(m, self.sidebar_rows, 0, SECTIONS.len()) {
407 self.section = SECTIONS[idx];
408 }
409 return EventState::Consumed;
410 }
411 if !self.content_rect.contains(pos) {
412 return EventState::NotConsumed;
413 }
414 if press {
415 self.focus = PreferencesFocus::Content;
416 }
417 let key = match self.section {
418 PreferencesSection::Workspaces => self.workspaces_section.handle_mouse(m),
419 PreferencesSection::Appearance => self.appearance_section.handle_mouse(m),
420 PreferencesSection::Display => self.display_section.handle_mouse(m),
421 PreferencesSection::Sorting => self.sorting_section.handle_mouse(m),
422 PreferencesSection::Indexing => self.indexing_section.handle_mouse(m),
423 PreferencesSection::Editor => self.editor_section.handle_mouse(m),
424 PreferencesSection::Server => None,
426 };
427 match key {
432 Some(key) => self.press(key, tx),
433 None => {
434 if press && self.section == PreferencesSection::Appearance {
435 self.sync_section();
436 }
437 EventState::Consumed
438 }
439 }
440 }
441
442 fn handle_overlay_mouse(&mut self, m: &MouseEvent, tx: &AppTx) -> EventState {
443 let esc = KeyEvent::from(KeyCode::Esc);
444 let enter = KeyEvent::from(KeyCode::Enter);
445 let press = matches!(m.kind, MouseEventKind::Down(_));
446 match &mut self.overlay {
447 Overlay::None => EventState::NotConsumed,
448 Overlay::IndexingProgress(IndexingProgressState::Running { .. }) => {
450 EventState::Consumed
451 }
452 Overlay::IndexingProgress(_) | Overlay::VaultConflict(_) => {
454 if press {
455 return self.press(enter, tx);
456 }
457 EventState::Consumed
458 }
459 Overlay::FileBrowser(fb) => {
460 if is_press_outside(m, self.overlay_rect) {
461 return self.press(esc, tx);
462 }
463 if let Some(key) = self.browser_hints.hit(m) {
464 return self.press(key, tx);
465 }
466 let total = fb.entries.len() + usize::from(fb.has_parent);
467 match m.kind {
468 MouseEventKind::ScrollUp => self.press(KeyEvent::from(KeyCode::Up), tx),
469 MouseEventKind::ScrollDown => self.press(KeyEvent::from(KeyCode::Down), tx),
470 _ => {
471 if let Some(idx) =
472 list_index_at(m, self.browser_rows, fb.list_state.offset(), total)
473 {
474 if fb.list_state.selected() == Some(idx) {
477 return self.press(enter, tx);
478 }
479 fb.list_state.select(Some(idx));
480 }
481 EventState::Consumed
482 }
483 }
484 }
485 Overlay::ConfirmFullReindex { .. } | Overlay::ConfirmSave { .. } => {
488 if is_press_outside(m, self.overlay_rect) {
489 return self.press(esc, tx);
490 }
491 let first = match &mut self.overlay {
492 Overlay::ConfirmFullReindex { focused_button } => {
493 self.reindex_buttons.hit(m).map(|idx| {
494 *focused_button = if idx == 0 {
495 ConfirmButton::Cancel
496 } else {
497 ConfirmButton::Confirm
498 };
499 })
500 }
501 Overlay::ConfirmSave { focused_button } => {
502 self.save_buttons.hit(m).map(|idx| {
503 *focused_button = if idx == 0 {
504 SaveButton::Save
505 } else {
506 SaveButton::Discard
507 };
508 })
509 }
510 _ => None,
511 };
512 match first {
513 Some(()) => self.press(enter, tx),
514 None => EventState::Consumed,
515 }
516 }
517 }
518 }
519}
520
521fn button_line(inner: Rect) -> Rect {
524 if inner.height < 4 {
525 return Rect::default();
526 }
527 Rect::new(inner.x + 1, inner.y + 3, inner.width.saturating_sub(1), 1)
528}
529
530const SECTIONS: [PreferencesSection; 7] = [
532 PreferencesSection::Workspaces,
533 PreferencesSection::Appearance,
534 PreferencesSection::Display,
535 PreferencesSection::Sorting,
536 PreferencesSection::Indexing,
537 PreferencesSection::Editor,
538 PreferencesSection::Server,
539];
540
541#[async_trait]
544impl AppScreen for PreferencesScreen {
545 fn get_kind(&self) -> ScreenKind {
546 ScreenKind::Preferences
547 }
548
549 fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
550 self.open_guard.observe_input(event);
551 if let InputEvent::Mouse(m) = event {
552 self.pointer = Some(Position::new(m.column, m.row));
553 return self.handle_mouse(m, tx);
554 }
555 match &mut self.overlay {
557 Overlay::None => {}
558
559 Overlay::FileBrowser(fb) => {
560 let InputEvent::Key(key) = event else {
561 return EventState::NotConsumed;
562 };
563 let offset = if fb.has_parent { 1 } else { 0 };
564 let total = fb.entries.len() + offset;
565 match key.code {
566 KeyCode::Esc => {
567 self.overlay = Overlay::None;
568 }
569 KeyCode::Up if total > 0 => {
570 let cur = fb.list_state.selected().unwrap_or(0);
571 fb.list_state.select(Some((cur + total - 1) % total));
572 }
573 KeyCode::Down if total > 0 => {
574 let cur = fb.list_state.selected().unwrap_or(0);
575 fb.list_state.select(Some((cur + 1) % total));
576 }
577 KeyCode::Left => {
578 fb.go_up();
579 }
580 KeyCode::Enter if key.modifiers.contains(KeyModifiers::CONTROL) => {
581 let chosen = fb.current_path.clone();
583 self.confirm_file_browser(chosen, tx);
584 }
585 KeyCode::Right | KeyCode::Enter => {
586 if let Some(idx) = fb.list_state.selected() {
587 if fb.has_parent && idx == 0 {
588 fb.go_up();
589 } else if let Some(entry) = fb.entries.get(idx - offset).cloned() {
590 fb.navigate_into(entry);
591 }
592 }
593 }
594 KeyCode::Char('c') if key.modifiers.is_empty() => {
595 let chosen = fb.current_path.clone();
596 self.confirm_file_browser(chosen, tx);
597 }
598 KeyCode::Char(c)
601 if !key
602 .modifiers
603 .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
604 {
605 fb.jump_to_char(c);
606 }
607 _ => {}
608 }
609 return EventState::Consumed;
610 }
611
612 Overlay::ConfirmFullReindex { focused_button } => {
613 let InputEvent::Key(key) = event else {
614 return EventState::NotConsumed;
615 };
616 match key.code {
617 KeyCode::Esc => {
618 self.overlay = Overlay::None;
619 }
620 KeyCode::Left | KeyCode::Char('h') => {
621 *focused_button = ConfirmButton::Cancel;
622 }
623 KeyCode::Right | KeyCode::Char('l') => {
624 *focused_button = ConfirmButton::Confirm;
625 }
626 KeyCode::Enter => {
627 if *focused_button == ConfirmButton::Confirm {
628 let s = self.settings.read().unwrap();
629 let Some(workspace) = s.resolve_workspace_path() else {
630 drop(s);
631 self.overlay = Overlay::None;
632 return EventState::Consumed;
633 };
634 let workspace_name = s
635 .workspace_config
636 .as_ref()
637 .map(|wc| wc.global.current_workspace.clone())
638 .filter(|n| !n.is_empty());
639 let cache_path = workspace_name.as_ref().map(|n| s.index_for(n));
640 drop(s);
641 let tx2 = tx.clone();
642 let handle = tokio::spawn(async move {
643 let mut config = VaultConfig::new(workspace);
644 if let Some(path) = cache_path {
645 config = config.with_index(path);
646 }
647 let event = match NoteVault::new(config).await {
648 Err(e) => AppEvent::IndexingDone(Err(e.to_string())),
649 Ok(vault) => {
650 let result = vault.recreate_index().await;
651 vault.close().await;
654 match result {
655 Ok(r) => AppEvent::IndexingDone(Ok(r.duration)),
656 Err(e @ VaultError::CaseConflict { .. }) => {
657 AppEvent::VaultConflict(e.to_string())
658 }
659 Err(e) => AppEvent::IndexingDone(Err(e.to_string())),
660 }
661 }
662 };
663 tx2.send(event).ok();
664 });
665 self.overlay = Overlay::IndexingProgress(spawn_running(handle, tx));
666 } else {
667 self.overlay = Overlay::None;
668 }
669 }
670 _ => {}
671 }
672 return EventState::Consumed;
673 }
674
675 Overlay::ConfirmSave { focused_button } => {
676 let InputEvent::Key(key) = event else {
677 return EventState::NotConsumed;
678 };
679 match key.code {
680 KeyCode::Esc => {
681 self.overlay = Overlay::None;
682 }
683 KeyCode::Left | KeyCode::Char('h') => {
684 *focused_button = SaveButton::Save;
685 }
686 KeyCode::Right | KeyCode::Char('l') => {
687 *focused_button = SaveButton::Discard;
688 }
689 KeyCode::Enter => {
690 if *focused_button == SaveButton::Save {
691 self.overlay = Overlay::None;
692 self.do_save(tx);
693 } else {
694 *self.settings.write().unwrap() = self.initial_settings.clone();
699 tx.send(AppEvent::ClosePreferences).ok();
700 }
701 }
702 _ => {}
703 }
704 return EventState::Consumed;
705 }
706
707 Overlay::IndexingProgress(state) => {
708 match state {
709 IndexingProgressState::Running { .. } => {
710 return EventState::Consumed; }
712 IndexingProgressState::Done(_) | IndexingProgressState::Failed(_) => {
713 let InputEvent::Key(key) = event else {
714 return EventState::NotConsumed;
715 };
716 if key.code == KeyCode::Enter || key.code == KeyCode::Esc {
717 self.overlay = Overlay::None;
718 }
719 return EventState::Consumed;
720 }
721 }
722 }
723
724 Overlay::VaultConflict(_) => {
725 let InputEvent::Key(key) = event else {
726 return EventState::NotConsumed;
727 };
728 if key.code == KeyCode::Enter || key.code == KeyCode::Esc {
729 self.overlay = Overlay::None;
730 }
731 return EventState::Consumed;
732 }
733 }
734
735 let InputEvent::Key(key) = event else {
737 return EventState::NotConsumed;
738 };
739 match key.code {
740 KeyCode::Esc => {
741 let changed = *self.settings.read().unwrap() != self.initial_settings;
742 if !changed {
743 tx.send(AppEvent::ClosePreferences).ok();
744 } else {
745 self.overlay = Overlay::ConfirmSave {
746 focused_button: SaveButton::Save,
747 };
748 }
749 EventState::Consumed
750 }
751 KeyCode::Tab => {
752 self.focus = match self.focus {
753 PreferencesFocus::Sidebar => PreferencesFocus::Content,
754 PreferencesFocus::Content => PreferencesFocus::Sidebar,
755 };
756 EventState::Consumed
757 }
758 _ => match self.focus {
759 PreferencesFocus::Sidebar => match key.code {
760 KeyCode::Down | KeyCode::Char('j') => {
761 self.section = match self.section {
762 PreferencesSection::Workspaces => PreferencesSection::Appearance,
763 PreferencesSection::Appearance => PreferencesSection::Display,
764 PreferencesSection::Display => PreferencesSection::Sorting,
765 PreferencesSection::Sorting => PreferencesSection::Indexing,
766 PreferencesSection::Indexing => PreferencesSection::Editor,
767 PreferencesSection::Editor => PreferencesSection::Server,
768 PreferencesSection::Server => PreferencesSection::Workspaces,
769 };
770 EventState::Consumed
771 }
772 KeyCode::Up | KeyCode::Char('k') => {
773 self.section = match self.section {
774 PreferencesSection::Workspaces => PreferencesSection::Server,
775 PreferencesSection::Appearance => PreferencesSection::Workspaces,
776 PreferencesSection::Display => PreferencesSection::Appearance,
777 PreferencesSection::Sorting => PreferencesSection::Display,
778 PreferencesSection::Indexing => PreferencesSection::Sorting,
779 PreferencesSection::Editor => PreferencesSection::Indexing,
780 PreferencesSection::Server => PreferencesSection::Editor,
781 };
782 EventState::Consumed
783 }
784 KeyCode::Enter => {
785 self.focus = PreferencesFocus::Content;
786 EventState::Consumed
787 }
788 _ => EventState::NotConsumed,
789 },
790 PreferencesFocus::Content => {
791 let app_event = InputEvent::Key(*key);
792 match self.section {
793 PreferencesSection::Appearance => {
794 let r = self.appearance_section.handle_input(&app_event, tx);
795 if r.is_consumed() {
797 self.sync_section();
798 }
799 r
800 }
801 PreferencesSection::Display => {
802 let r = self.display_section.handle_input(&app_event, tx);
803 self.sync_section();
804 r
805 }
806 PreferencesSection::Sorting => {
807 let r = self.sorting_section.handle_input(&app_event, tx);
808 self.sync_section();
809 r
810 }
811 PreferencesSection::Workspaces => {
812 let pre_mode = self.workspaces_section.mode().clone();
814 let pre_selected = self
815 .workspaces_section
816 .selected_name()
817 .map(|s| s.to_string());
818
819 let r = self.workspaces_section.handle_input(&app_event, tx);
820
821 let post_mode = self.workspaces_section.mode().clone();
822
823 if pre_mode == WorkspaceMode::Creating
827 && post_mode == WorkspaceMode::Creating
828 && key.code == KeyCode::Enter
829 {
830 let name = self.workspaces_section.input().trim().to_string();
831 if !name.is_empty() {
832 let exists = self
834 .settings
835 .read()
836 .unwrap()
837 .workspace_config
838 .as_ref()
839 .is_some_and(|wc| wc.workspaces.contains_key(&name));
840 if exists {
841 self.workspaces_section.set_error(format!(
842 "Workspace '{}' already exists.",
843 name
844 ));
845 } else {
846 self.pending_create_name = Some(name);
847 self.workspaces_section.reset_mode();
848 }
849 }
850 }
851
852 if pre_mode == WorkspaceMode::Renaming
854 && post_mode == WorkspaceMode::Renaming
855 && key.code == KeyCode::Enter
856 {
857 let new_name = self.workspaces_section.input().trim().to_string();
858 let duplicate = !new_name.is_empty()
860 && pre_selected.as_deref() != Some(&new_name)
861 && self
862 .settings
863 .read()
864 .unwrap()
865 .workspace_config
866 .as_ref()
867 .is_some_and(|wc| wc.workspaces.contains_key(&new_name));
868 if duplicate {
869 self.workspaces_section.set_error(format!(
870 "Workspace '{}' already exists.",
871 new_name
872 ));
873 } else if let Some(old_name) = pre_selected.as_deref()
874 && !new_name.is_empty()
875 && new_name != old_name
876 {
877 let mut s = self.settings.write().unwrap();
878 if let Some(ref mut wc) = s.workspace_config {
879 wc.rename_workspace(old_name, new_name.clone());
887 }
888 }
889 self.workspaces_section.reset_mode();
890 self.workspaces_section
891 .refresh(&self.settings.read().unwrap());
892 }
893
894 if pre_mode == WorkspaceMode::ConfirmDelete
896 && post_mode == WorkspaceMode::ConfirmDelete
897 && key.code == KeyCode::Char('y')
898 {
899 if let Some(name) = pre_selected.as_deref() {
900 let artifacts = {
909 let mut s = self.settings.write().unwrap();
910 let removable =
911 s.workspace_config.as_ref().is_some_and(|wc| {
912 name != wc.global.current_workspace
913 && wc.workspaces.contains_key(name)
914 });
915 removable.then(|| {
916 let artifacts = s.workspace_artifacts(name);
917 if let Some(ref mut wc) = s.workspace_config {
918 wc.workspaces.remove(name);
919 }
920 artifacts
921 })
922 };
923 if let Some((index, history)) = artifacts {
924 let leftovers =
925 crate::settings::delete_artifacts(&index, &history);
926 if !leftovers.is_empty() {
927 tracing::warn!(
928 "workspace '{name}' removed, but these files \
929 stayed behind:\n{}",
930 leftovers.join("\n")
931 );
932 }
933 }
934 }
935 self.workspaces_section.reset_mode();
936 self.workspaces_section
937 .refresh(&self.settings.read().unwrap());
938 }
939
940 r
941 }
942 PreferencesSection::Indexing => {
943 self.indexing_section.handle_input(&app_event, tx)
944 }
945 PreferencesSection::Editor => {
946 let r = self.editor_section.handle_input(&app_event, tx);
947 self.sync_section();
948 r
949 }
950 PreferencesSection::Server => {
951 let r = self.server_section.handle_input(&app_event, tx);
952 if r.is_consumed() {
953 self.sync_section();
954 }
955 r
956 }
957 }
958 }
959 },
960 }
961 }
962
963 async fn handle_app_message(&mut self, msg: AppEvent, tx: &AppTx) {
964 match msg {
965 AppEvent::OpenFileBrowser => {
966 let starting_dir = self
967 .settings
968 .read()
969 .unwrap()
970 .resolve_workspace_path()
971 .or_else(|| kimun_core::system::browse_root().ok())
972 .map(|p| p.into_path_buf())
973 .unwrap_or_else(|| PathBuf::from("/"));
974 self.overlay = Overlay::FileBrowser(FileBrowserState::load(starting_dir));
975 }
976 AppEvent::TriggerFastReindex => {
977 let s = self.settings.read().unwrap();
980 let Some(workspace) = s.resolve_workspace_path() else {
981 drop(s);
982 tx.send(AppEvent::IndexingDone(Err("No workspace set".to_string())))
983 .ok();
984 return;
985 };
986 let workspace_name = s
987 .workspace_config
988 .as_ref()
989 .map(|wc| wc.global.current_workspace.clone())
990 .filter(|n| !n.is_empty());
991 let cache_path = workspace_name.as_ref().map(|n| s.index_for(n));
992 drop(s);
993 let tx2 = tx.clone();
994 let handle = tokio::spawn(async move {
995 let result = async {
996 let mut config = VaultConfig::new(workspace);
997 if let Some(path) = cache_path {
998 config = config.with_index(path);
999 }
1000 let vault = NoteVault::new(config).await.map_err(|e| e.to_string())?;
1001 let result = vault.index_notes(NotesValidation::Fast).await;
1002 vault.close().await;
1005 result.map_err(|e| e.to_string()).map(|r| r.duration)
1006 }
1007 .await;
1008 tx2.send(AppEvent::IndexingDone(result)).ok();
1009 });
1010 self.overlay = Overlay::IndexingProgress(spawn_running(handle, tx));
1011 }
1012 AppEvent::TriggerFullReindex => {
1013 self.overlay = Overlay::ConfirmFullReindex {
1014 focused_button: ConfirmButton::Cancel,
1015 };
1016 }
1017 AppEvent::IndexingDone(result) => match result {
1018 Ok(duration) => {
1019 self.settings.write().unwrap().report_indexed();
1020 if self.pending_save_after_index {
1021 self.pending_save_after_index = false;
1022 self.settings.read().unwrap().save_to_disk().ok();
1023 tx.send(AppEvent::PreferencesSaved).ok();
1024 } else {
1025 self.overlay =
1026 Overlay::IndexingProgress(IndexingProgressState::Done(duration));
1027 }
1028 }
1029 Err(msg) => {
1030 self.pending_save_after_index = false;
1031 self.overlay = Overlay::IndexingProgress(IndexingProgressState::Failed(msg));
1032 }
1033 },
1034 _ => {}
1035 }
1036 }
1037
1038 fn render(&mut self, f: &mut Frame) {
1039 crate::components::clickable::clear();
1041 let theme = self.theme.clone();
1042 f.render_widget(Block::default().style(theme.base_style()), f.area());
1043
1044 let rows = Layout::default()
1045 .direction(Direction::Vertical)
1046 .constraints([
1047 Constraint::Length(3),
1048 Constraint::Min(0),
1049 Constraint::Length(1),
1050 ])
1051 .split(f.area());
1052
1053 let header = Block::default()
1054 .title("Preferences")
1055 .borders(Borders::ALL)
1056 .border_style(Style::default().fg(theme.border_dim.to_ratatui()))
1057 .style(theme.base_style())
1058 .title_style(Style::default().fg(theme.accent.to_ratatui()));
1059 f.render_widget(header, rows[0]);
1060
1061 self.footer.render(
1063 f,
1064 rows[2],
1065 Style::default()
1066 .fg(theme.gray.to_ratatui())
1067 .bg(theme.bg.to_ratatui()),
1068 &theme,
1069 );
1070
1071 let cols = Layout::default()
1072 .direction(Direction::Horizontal)
1073 .constraints([Constraint::Length(20), Constraint::Min(0)])
1074 .split(rows[1]);
1075
1076 let sidebar_focused = self.focus == PreferencesFocus::Sidebar;
1078 let active_idx = match self.section {
1079 PreferencesSection::Workspaces => 0,
1080 PreferencesSection::Appearance => 1,
1081 PreferencesSection::Display => 2,
1082 PreferencesSection::Sorting => 3,
1083 PreferencesSection::Indexing => 4,
1084 PreferencesSection::Editor => 5,
1085 PreferencesSection::Server => 6,
1086 };
1087 let items: Vec<ListItem> = [
1088 "Workspaces",
1089 "Appearance",
1090 "Display",
1091 "Sorting",
1092 "Indexing",
1093 "Editor",
1094 "Server",
1095 ]
1096 .iter()
1097 .enumerate()
1098 .map(|(i, name)| {
1099 let prefix = if i == active_idx { "> " } else { " " };
1100 let fg = if i == active_idx {
1101 theme.accent.to_ratatui()
1102 } else {
1103 theme.fg.to_ratatui()
1104 };
1105 ListItem::new(format!("{}{}", prefix, name))
1106 .style(Style::default().fg(fg).bg(theme.bg_panel.to_ratatui()))
1107 })
1108 .collect();
1109 let sidebar_block = Block::default()
1110 .borders(Borders::ALL)
1111 .border_style(theme.border_style(sidebar_focused))
1112 .style(theme.panel_style());
1113 self.sidebar_rows = sidebar_block.inner(cols[0]);
1114 let sidebar_list = List::new(items).block(sidebar_block);
1115 f.render_widget(sidebar_list, cols[0]);
1116 self.content_rect = cols[1];
1117
1118 let content_focused = self.focus == PreferencesFocus::Content;
1120 match self.section {
1121 PreferencesSection::Appearance => {
1122 self.appearance_section
1123 .render(f, cols[1], &theme, content_focused)
1124 }
1125 PreferencesSection::Display => {
1126 self.display_section
1127 .render(f, cols[1], &theme, content_focused)
1128 }
1129 PreferencesSection::Sorting => {
1130 self.sorting_section
1131 .render(f, cols[1], &theme, content_focused)
1132 }
1133 PreferencesSection::Workspaces => {
1134 self.workspaces_section
1135 .render(f, cols[1], &theme, content_focused)
1136 }
1137 PreferencesSection::Indexing => {
1138 self.indexing_section
1139 .render(f, cols[1], &theme, content_focused)
1140 }
1141 PreferencesSection::Editor => {
1142 self.editor_section
1143 .render(f, cols[1], &theme, content_focused)
1144 }
1145 PreferencesSection::Server => {
1146 self.server_section
1147 .render(f, cols[1], &theme, content_focused)
1148 }
1149 }
1150
1151 let shown =
1154 (!matches!(self.overlay, Overlay::None)).then(|| std::mem::discriminant(&self.overlay));
1155 if shown.is_some() {
1156 crate::components::clickable::clear();
1157 }
1158 if std::mem::replace(&mut self.shown_overlay, shown) != shown {
1159 match shown {
1160 Some(_) => self.open_guard.opened(std::time::Instant::now()),
1161 None => self.open_guard.closed(),
1162 }
1163 }
1164 self.render_overlay(f, &theme);
1165 crate::components::clickable::apply_hover(f.buffer_mut(), self.pointer, &theme);
1166 }
1167}
1168
1169impl PreferencesScreen {
1170 fn render_overlay(&mut self, f: &mut Frame, theme: &Theme) {
1171 match &mut self.overlay {
1172 Overlay::None => {}
1173
1174 Overlay::FileBrowser(fb) => {
1175 let area = crate::components::centered_rect(60, 80, f.area());
1176 self.overlay_rect = area;
1177 f.render_widget(Clear, area);
1178 let block = Block::default()
1179 .title("Select Vault Directory")
1180 .borders(Borders::ALL)
1181 .border_style(Style::default().fg(theme.accent.to_ratatui()))
1182 .style(theme.base_style());
1183 let inner = block.inner(area);
1184 f.render_widget(block, area);
1185
1186 let rows = Layout::default()
1187 .direction(Direction::Vertical)
1188 .constraints([
1189 Constraint::Length(1),
1190 Constraint::Min(0),
1191 Constraint::Length(1),
1192 ])
1193 .split(inner);
1194
1195 let path_str = fb.current_path.to_string_lossy().into_owned();
1196 f.render_widget(Paragraph::new(path_str).style(theme.base_style()), rows[0]);
1197
1198 let mut items: Vec<ListItem> = Vec::new();
1199 if fb.has_parent {
1200 items.push(
1201 ListItem::new(" ../").style(
1202 Style::default()
1203 .fg(theme.fg_secondary.to_ratatui())
1204 .bg(theme.bg.to_ratatui()),
1205 ),
1206 );
1207 }
1208 for e in &fb.entries {
1209 let name = e.file_name().unwrap_or_default().to_string_lossy();
1210 items.push(
1211 ListItem::new(format!(" {}/", name)).style(
1212 Style::default()
1213 .fg(theme.fg.to_ratatui())
1214 .bg(theme.bg.to_ratatui()),
1215 ),
1216 );
1217 }
1218 let list = List::new(items)
1219 .highlight_symbol("▶ ")
1220 .highlight_style(Style::default().add_modifier(Modifier::BOLD));
1221 f.render_stateful_widget(list, rows[1], &mut fb.list_state);
1222 self.browser_rows = rows[1];
1223 self.browser_hints
1224 .render(f, rows[2], theme.base_style(), theme);
1225 }
1226
1227 Overlay::ConfirmFullReindex { focused_button } => {
1228 let area = fixed_centered_rect(44, 6, f.area());
1229 self.overlay_rect = area;
1230 f.render_widget(Clear, area);
1231 let block = Block::default()
1232 .title("Full Reindex")
1233 .borders(Borders::ALL)
1234 .border_style(Style::default().fg(theme.accent.to_ratatui()))
1235 .style(theme.base_style());
1236 let inner = block.inner(area);
1237 f.render_widget(block, area);
1238 f.render_widget(
1239 Paragraph::new("\n This may take a while.").style(theme.base_style()),
1240 inner,
1241 );
1242 let focused = match focused_button {
1243 ConfirmButton::Cancel => 0,
1244 ConfirmButton::Confirm => 1,
1245 };
1246 self.reindex_buttons.set_focused(Some(focused));
1247 self.reindex_buttons.render(f, button_line(inner), theme);
1248 }
1249
1250 Overlay::ConfirmSave { focused_button } => {
1251 let area = fixed_centered_rect(44, 6, f.area());
1252 self.overlay_rect = area;
1253 f.render_widget(Clear, area);
1254 let block = Block::default()
1255 .title("Save Preferences?")
1256 .borders(Borders::ALL)
1257 .border_style(Style::default().fg(theme.accent.to_ratatui()))
1258 .style(theme.base_style());
1259 let inner = block.inner(area);
1260 f.render_widget(block, area);
1261 f.render_widget(
1262 Paragraph::new("\n You have unsaved changes.").style(theme.base_style()),
1263 inner,
1264 );
1265 let focused = match focused_button {
1266 SaveButton::Save => 0,
1267 SaveButton::Discard => 1,
1268 };
1269 self.save_buttons.set_focused(Some(focused));
1270 self.save_buttons.render(f, button_line(inner), theme);
1271 }
1272
1273 Overlay::IndexingProgress(state) => {
1274 render_indexing_overlay(
1275 f,
1276 state,
1277 &mut self.throbber_state,
1278 theme,
1279 "Reindex in progress…",
1280 );
1281 }
1282
1283 Overlay::VaultConflict(msg) => {
1284 let area = fixed_centered_rect(60, 9, f.area());
1285 f.render_widget(Clear, area);
1286 let block = Block::default()
1287 .title("Vault Error")
1288 .borders(Borders::ALL)
1289 .border_style(Style::default().fg(theme.accent.to_ratatui()))
1290 .style(theme.base_style());
1291 let inner = block.inner(area);
1292 f.render_widget(block, area);
1293 f.render_widget(
1294 Paragraph::new(format!("\n {}\n\n [ OK ]", msg))
1295 .style(theme.base_style())
1296 .wrap(Wrap { trim: false }),
1297 inner,
1298 );
1299 }
1300 }
1301 }
1302}
1303
1304#[cfg(test)]
1305mod file_browser_tests {
1306 use super::*;
1307 use std::fs;
1308
1309 fn make_temp_dir(name: &str) -> PathBuf {
1310 let path = std::env::temp_dir().join(format!("kimun_test_{}", name));
1311 let _ = fs::remove_dir_all(&path);
1312 fs::create_dir_all(&path).unwrap();
1313 path
1314 }
1315
1316 #[test]
1317 fn load_returns_only_directories() {
1318 let root = make_temp_dir("fb_only_dirs");
1319 fs::create_dir(root.join("alpha")).unwrap();
1320 fs::create_dir(root.join("beta")).unwrap();
1321 fs::write(root.join("note.md"), b"text").unwrap();
1322 let state = FileBrowserState::load(root.clone());
1323 assert_eq!(state.entries.len(), 2);
1324 assert!(state.entries.iter().all(|e| e.is_dir()));
1325 }
1326
1327 #[test]
1328 fn load_sorts_alphabetically() {
1329 let root = make_temp_dir("fb_sorted");
1330 fs::create_dir(root.join("zebra")).unwrap();
1331 fs::create_dir(root.join("alpha")).unwrap();
1332 fs::create_dir(root.join("mango")).unwrap();
1333 let state = FileBrowserState::load(root.clone());
1334 let names: Vec<_> = state
1335 .entries
1336 .iter()
1337 .map(|e| e.file_name().unwrap().to_str().unwrap().to_string())
1338 .collect();
1339 assert_eq!(names, vec!["alpha", "mango", "zebra"]);
1340 }
1341
1342 #[test]
1343 fn load_handles_empty_directory() {
1344 let root = make_temp_dir("fb_empty");
1345 let state = FileBrowserState::load(root.clone());
1346 assert_eq!(state.current_path, root);
1347 assert!(state.entries.is_empty());
1348 assert!(state.has_parent);
1350 assert_eq!(state.list_state.selected(), Some(0));
1351 }
1352
1353 #[test]
1354 fn load_root_has_no_parent_entry() {
1355 let state = FileBrowserState::load(PathBuf::from("/"));
1356 assert!(!state.has_parent);
1357 if state.entries.is_empty() {
1359 assert_eq!(state.list_state.selected(), None);
1360 } else {
1361 assert_eq!(state.list_state.selected(), Some(0));
1362 }
1363 }
1364
1365 #[test]
1366 fn navigate_into_updates_path_and_reloads() {
1367 let root = make_temp_dir("fb_nav");
1368 let sub = root.join("sub");
1369 fs::create_dir_all(&sub).unwrap();
1370 fs::create_dir(sub.join("child")).unwrap();
1371 let mut state = FileBrowserState::load(root.clone());
1372 state.navigate_into(sub.clone());
1373 assert_eq!(state.current_path, sub);
1374 assert_eq!(state.entries.len(), 1);
1375 assert_eq!(state.entries[0].file_name().unwrap(), "child");
1376 }
1377
1378 #[test]
1379 fn go_up_updates_to_parent() {
1380 let root = make_temp_dir("fb_go_up");
1381 let sub = root.join("sub");
1382 fs::create_dir_all(&sub).unwrap();
1383 let mut state = FileBrowserState::load(sub.clone());
1384 state.go_up();
1385 assert_eq!(state.current_path, root);
1386 }
1387}
1388
1389#[cfg(test)]
1390mod settings_screen_tests {
1391 use std::sync::{Arc, RwLock};
1392 use std::time::Duration;
1393
1394 use super::*;
1395 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
1396 use tokio::sync::mpsc::unbounded_channel;
1397
1398 fn key(code: KeyCode) -> InputEvent {
1399 InputEvent::Key(KeyEvent {
1400 code,
1401 modifiers: KeyModifiers::NONE,
1402 kind: KeyEventKind::Press,
1403 state: KeyEventState::NONE,
1404 })
1405 }
1406
1407 fn shared_defaults() -> SharedSettings {
1408 Arc::new(RwLock::new(AppSettings::default()))
1409 }
1410
1411 fn abs(unix_style: &str) -> PathBuf {
1419 if cfg!(windows) {
1420 PathBuf::from(format!(
1421 "C:\\{}",
1422 unix_style.trim_start_matches('/').replace('/', "\\")
1423 ))
1424 } else {
1425 PathBuf::from(unix_style)
1426 }
1427 }
1428
1429 fn make_screen() -> PreferencesScreen {
1430 PreferencesScreen::new(shared_defaults())
1431 }
1432
1433 #[test]
1434 fn esc_sends_close_settings_when_no_changes() {
1435 let (tx, mut rx) = unbounded_channel();
1436 let mut screen = make_screen();
1437 screen.handle_input(&key(KeyCode::Esc), &tx);
1438 let msg = rx.try_recv().expect("expected message");
1439 assert!(matches!(msg, AppEvent::ClosePreferences));
1440 }
1441
1442 #[test]
1443 fn esc_shows_confirm_save_when_settings_changed() {
1444 let (tx, mut rx) = unbounded_channel();
1445 let mut screen = make_screen();
1446 screen
1447 .settings
1448 .write()
1449 .unwrap()
1450 .set_theme("Gruvbox Light".to_string());
1451 screen.handle_input(&key(KeyCode::Esc), &tx);
1452 assert!(rx.try_recv().is_err(), "no message should be sent yet");
1453 assert!(matches!(screen.overlay, Overlay::ConfirmSave { .. }));
1454 }
1455
1456 #[test]
1457 fn confirm_save_discard_sends_close_settings() {
1458 let (tx, mut rx) = unbounded_channel();
1459 let mut screen = make_screen();
1460 screen
1461 .settings
1462 .write()
1463 .unwrap()
1464 .set_theme("Gruvbox Light".to_string());
1465 screen.overlay = Overlay::ConfirmSave {
1466 focused_button: SaveButton::Discard,
1467 };
1468 screen.handle_input(&key(KeyCode::Enter), &tx);
1469 let msg = rx.try_recv().expect("expected message");
1470 assert!(matches!(msg, AppEvent::ClosePreferences));
1471 }
1472
1473 #[test]
1474 fn confirm_save_save_vault_unchanged_sends_settings_saved() {
1475 let (tx, mut rx) = unbounded_channel();
1476 let mut screen = make_screen();
1477 screen
1478 .settings
1479 .write()
1480 .unwrap()
1481 .set_theme("Gruvbox Light".to_string());
1482 screen.overlay = Overlay::ConfirmSave {
1483 focused_button: SaveButton::Save,
1484 };
1485 screen.handle_input(&key(KeyCode::Enter), &tx);
1486 let msg = rx.try_recv().expect("expected message");
1487 assert!(matches!(msg, AppEvent::PreferencesSaved));
1488 assert!(rx.try_recv().is_err());
1489 }
1490
1491 #[tokio::test]
1492 async fn confirm_save_vault_changed_sets_pending_and_shows_progress() {
1493 let (tx, _rx) = unbounded_channel();
1494 let mut settings = AppSettings::default();
1495 let mut wc = crate::settings::workspace_config::WorkspaceConfig::new_empty();
1496 wc.add_workspace("main".to_string(), abs("/original/path"))
1497 .unwrap();
1498 settings.workspace_config = Some(wc);
1499 let shared = Arc::new(RwLock::new(settings));
1500 let mut screen = PreferencesScreen::new(shared);
1501 screen
1502 .settings
1503 .write()
1504 .unwrap()
1505 .set_workspace_path("main", abs("/new/path"));
1506 screen.overlay = Overlay::ConfirmSave {
1507 focused_button: SaveButton::Save,
1508 };
1509 screen.handle_input(&key(KeyCode::Enter), &tx);
1510 assert!(screen.pending_save_after_index);
1511 assert!(matches!(
1512 screen.overlay,
1513 Overlay::IndexingProgress(IndexingProgressState::Running { .. })
1514 ));
1515 }
1516
1517 #[tokio::test]
1518 async fn indexing_done_ok_with_pending_auto_closes() {
1519 let (tx, mut rx) = unbounded_channel();
1520 let mut screen = make_screen();
1521 screen.pending_save_after_index = true;
1522 screen.overlay = Overlay::IndexingProgress(IndexingProgressState::Running {
1523 work: tokio::spawn(async {}),
1524 ticker: tokio::spawn(async {}),
1525 });
1526 screen
1527 .handle_app_message(AppEvent::IndexingDone(Ok(Duration::from_secs(1))), &tx)
1528 .await;
1529 let msg = rx.try_recv().expect("expected PreferencesSaved");
1530 assert!(matches!(msg, AppEvent::PreferencesSaved));
1531 assert!(!screen.pending_save_after_index);
1532 }
1533
1534 #[tokio::test]
1535 async fn indexing_done_err_with_pending_shows_failed_no_save() {
1536 let (tx, mut rx) = unbounded_channel();
1537 let mut screen = make_screen();
1538 screen.pending_save_after_index = true;
1539 screen.overlay = Overlay::IndexingProgress(IndexingProgressState::Running {
1540 work: tokio::spawn(async {}),
1541 ticker: tokio::spawn(async {}),
1542 });
1543 screen
1544 .handle_app_message(AppEvent::IndexingDone(Err("disk error".to_string())), &tx)
1545 .await;
1546 assert!(
1547 rx.try_recv().is_err(),
1548 "no PreferencesSaved when index failed"
1549 );
1550 assert!(!screen.pending_save_after_index);
1551 assert!(matches!(
1552 screen.overlay,
1553 Overlay::IndexingProgress(IndexingProgressState::Failed(_))
1554 ));
1555 }
1556
1557 #[tokio::test]
1558 async fn indexing_done_ok_without_pending_shows_done() {
1559 let (tx, mut rx) = unbounded_channel();
1560 let mut screen = make_screen();
1561 screen.pending_save_after_index = false;
1562 screen.overlay = Overlay::IndexingProgress(IndexingProgressState::Running {
1563 work: tokio::spawn(async {}),
1564 ticker: tokio::spawn(async {}),
1565 });
1566 screen
1567 .handle_app_message(AppEvent::IndexingDone(Ok(Duration::from_secs(2))), &tx)
1568 .await;
1569 assert!(
1570 rx.try_recv().is_err(),
1571 "no auto-close when pending is false"
1572 );
1573 assert!(matches!(
1574 screen.overlay,
1575 Overlay::IndexingProgress(IndexingProgressState::Done(_))
1576 ));
1577 }
1578
1579 #[test]
1580 fn esc_blocked_while_indexing_running() {
1581 let rt = tokio::runtime::Runtime::new().unwrap();
1582 let (tx, mut rx) = unbounded_channel();
1583 let mut screen = make_screen();
1584 screen.overlay = Overlay::IndexingProgress(IndexingProgressState::Running {
1585 work: rt.spawn(async {}),
1586 ticker: rt.spawn(async {}),
1587 });
1588 screen.handle_input(&key(KeyCode::Esc), &tx);
1589 assert!(rx.try_recv().is_err(), "Esc must be blocked while indexing");
1590 }
1591
1592 #[tokio::test]
1593 async fn confirm_full_reindex_esc_closes_overlay() {
1594 let (tx, _rx) = unbounded_channel();
1595 let mut screen = make_screen();
1596 screen.overlay = Overlay::ConfirmFullReindex {
1597 focused_button: ConfirmButton::Cancel,
1598 };
1599 screen.handle_input(&key(KeyCode::Esc), &tx);
1600 assert!(matches!(screen.overlay, Overlay::None));
1601 }
1602
1603 #[test]
1604 fn new_with_error_sets_vault_conflict_overlay_with_message() {
1605 let screen =
1606 PreferencesScreen::new_with_error(shared_defaults(), "test error msg".to_string());
1607 match screen.overlay {
1608 Overlay::VaultConflict(ref msg) => {
1609 assert_eq!(msg, "test error msg");
1610 }
1611 _ => panic!("expected Overlay::VaultConflict(...)"),
1612 }
1613 }
1614}
1615
1616#[cfg(test)]
1617mod mouse_tests {
1618 use std::sync::{Arc, RwLock};
1619
1620 use super::*;
1621 use crate::components::events::AppEvent;
1622 use tokio::sync::mpsc::{UnboundedReceiver, unbounded_channel};
1623
1624 fn screen() -> PreferencesScreen {
1625 PreferencesScreen::new(Arc::new(RwLock::new(AppSettings::default())))
1626 }
1627
1628 fn draw(s: &mut PreferencesScreen) -> ratatui::buffer::Buffer {
1629 let mut t = ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 30)).unwrap();
1630 t.draw(|f| s.render(f)).unwrap();
1631 t.backend().buffer().clone()
1632 }
1633
1634 fn click(s: &mut PreferencesScreen, text: &str, dx: u16) -> UnboundedReceiver<AppEvent> {
1636 let buf = draw(s);
1637 let (x, y) = crate::test_support::find_text(&buf, text)
1638 .unwrap_or_else(|| panic!("{text:?} not drawn"));
1639 let (tx, rx) = unbounded_channel();
1640 s.handle_input(&crate::test_support::mouse_down_at(x + dx, y), &tx);
1641 rx
1642 }
1643
1644 fn settle_overlay(s: &mut PreferencesScreen) {
1647 draw(s);
1648 s.open_guard.closed();
1649 }
1650
1651 #[test]
1652 fn clicking_a_section_switches_to_it() {
1653 let mut s = screen();
1654 click(&mut s, "Display", 0);
1655 assert_eq!(s.section, PreferencesSection::Display);
1656 assert_eq!(s.focus, PreferencesFocus::Sidebar);
1657 }
1658
1659 #[test]
1660 fn display_checkbox_and_stepper_write_settings() {
1661 let mut s = screen();
1662 s.section = PreferencesSection::Display;
1663 let before = s.settings.read().unwrap().use_nerd_fonts;
1664 click(&mut s, "Use Nerd Fonts", 16);
1666 assert_eq!(s.settings.read().unwrap().use_nerd_fonts, !before);
1667 assert_eq!(s.focus, PreferencesFocus::Content);
1668
1669 let delay = s.settings.read().unwrap().leader_timeout_ms;
1670 click(&mut s, "▶", 0);
1671 assert_eq!(s.settings.read().unwrap().leader_timeout_ms, delay + 50);
1672 }
1673
1674 #[test]
1675 fn clicking_a_theme_previews_it() {
1676 let mut s = screen();
1677 s.section = PreferencesSection::Appearance;
1678 let current = s.settings.read().unwrap().theme.clone();
1679 let other = s
1680 .settings
1681 .read()
1682 .unwrap()
1683 .theme_list()
1684 .into_iter()
1685 .map(|t| t.name)
1686 .find(|n| *n != current)
1687 .unwrap();
1688 click(&mut s, &other, 0);
1689 assert_eq!(s.settings.read().unwrap().theme, other);
1690 }
1691
1692 #[test]
1693 fn sorting_value_cycles_on_click() {
1694 let mut s = screen();
1695 s.section = PreferencesSection::Sorting;
1696 let before = s.settings.read().unwrap().default_sort_field;
1697 click(&mut s, "[Name]", 1);
1698 assert_ne!(s.settings.read().unwrap().default_sort_field, before);
1699 }
1700
1701 #[test]
1702 fn editor_backend_steps_on_click() {
1703 let mut s = screen();
1704 s.section = PreferencesSection::Editor;
1705 let before = s.settings.read().unwrap().editor_backend;
1706 let buf = draw(&mut s);
1708 let (_, y) = crate::test_support::find_text(&buf, "Editor Backend").unwrap();
1709 let col = (0..100u16)
1710 .find(|&c| buf[(c, y + 1)].symbol() == "▶")
1711 .unwrap();
1712 let (tx, _rx) = unbounded_channel();
1713 s.handle_input(&crate::test_support::mouse_down_at(col, y + 1), &tx);
1714 assert_ne!(s.settings.read().unwrap().editor_backend, before);
1715 }
1716
1717 #[test]
1718 fn reindex_buttons_run_their_reindex() {
1719 let mut s = screen();
1720 s.section = PreferencesSection::Indexing;
1721 s.indexing_section = IndexingSection::new(true);
1722 let mut rx = click(&mut s, "Full Reindex", 0);
1723 assert!(matches!(rx.try_recv(), Ok(AppEvent::TriggerFullReindex)));
1724 let mut rx = click(&mut s, "Fast Reindex", 0);
1725 assert!(matches!(rx.try_recv(), Ok(AppEvent::TriggerFastReindex)));
1726 }
1727
1728 #[test]
1731 fn folder_picker_rows_and_choose_chip() {
1732 let dir = tempfile::TempDir::new().unwrap();
1733 std::fs::create_dir(dir.path().join("inner")).unwrap();
1734 let mut s = screen();
1735 s.overlay = Overlay::FileBrowser(FileBrowserState::load(dir.path().to_path_buf()));
1736 settle_overlay(&mut s);
1737 click(&mut s, "inner/", 0);
1738 click(&mut s, "inner/", 0);
1739 let Overlay::FileBrowser(fb) = &s.overlay else {
1740 panic!("picker still open")
1741 };
1742 assert!(fb.current_path.ends_with("inner"), "re-click opened it");
1743
1744 click(&mut s, "[c] Choose this folder", 0);
1745 assert!(
1746 matches!(s.overlay, Overlay::None),
1747 "choosing closes the picker"
1748 );
1749 }
1750
1751 #[test]
1754 fn hovering_sections_changes_no_settings() {
1755 let mut s = screen();
1756 for section in SECTIONS {
1757 s.section = section;
1758 draw(&mut s);
1759 let (tx, _rx) = unbounded_channel();
1760 for y in (0..30u16).step_by(3) {
1761 for x in (0..100u16).step_by(7) {
1762 s.handle_input(
1763 &InputEvent::Mouse(ratatui::crossterm::event::MouseEvent {
1764 kind: MouseEventKind::Moved,
1765 column: x,
1766 row: y,
1767 modifiers: KeyModifiers::NONE,
1768 }),
1769 &tx,
1770 );
1771 }
1772 }
1773 }
1774 assert_eq!(*s.settings.read().unwrap(), s.initial_settings);
1775 }
1776
1777 #[test]
1778 fn workspace_new_chip_starts_creating() {
1779 let mut s = screen();
1780 click(&mut s, "[n] New", 0);
1781 assert_eq!(*s.workspaces_section.mode(), WorkspaceMode::Creating);
1782 }
1783
1784 #[test]
1787 fn footer_esc_and_discard_button() {
1788 let mut s = screen();
1789 let mut rx = click(&mut s, "[Esc] Save & Close", 0);
1790 assert!(matches!(rx.try_recv(), Ok(AppEvent::ClosePreferences)));
1791
1792 s.section = PreferencesSection::Display;
1793 click(&mut s, "Use Nerd Fonts", 16);
1794 click(&mut s, "[Esc] Save & Close", 0);
1795 assert!(matches!(s.overlay, Overlay::ConfirmSave { .. }));
1796 settle_overlay(&mut s);
1797 let mut rx = click(&mut s, "Discard", 0);
1798 assert!(matches!(rx.try_recv(), Ok(AppEvent::ClosePreferences)));
1799 assert_eq!(*s.settings.read().unwrap(), s.initial_settings);
1800 }
1801
1802 #[test]
1805 fn a_right_click_presses_no_button() {
1806 let mut s = screen();
1807 s.overlay = Overlay::ConfirmSave {
1808 focused_button: SaveButton::Save,
1809 };
1810 settle_overlay(&mut s);
1811 let buf = draw(&mut s);
1812 let (x, y) = crate::test_support::find_text(&buf, "Discard").unwrap();
1813 let (tx, mut rx) = unbounded_channel();
1814 s.handle_input(
1815 &InputEvent::Mouse(ratatui::crossterm::event::MouseEvent {
1816 kind: MouseEventKind::Down(ratatui::crossterm::event::MouseButton::Right),
1817 column: x,
1818 row: y,
1819 modifiers: KeyModifiers::NONE,
1820 }),
1821 &tx,
1822 );
1823 assert!(rx.try_recv().is_err());
1824 assert!(matches!(s.overlay, Overlay::ConfirmSave { .. }));
1825 }
1826
1827 #[test]
1830 fn overlay_guard_and_outside_press() {
1831 let mut s = screen();
1832 s.open_guard
1834 .observe_input(&crate::test_support::mouse_down_at(5, 5));
1835 s.overlay = Overlay::ConfirmSave {
1836 focused_button: SaveButton::Save,
1837 };
1838 let mut rx = click(&mut s, "Discard", 0);
1839 assert!(
1840 rx.try_recv().is_err(),
1841 "the opening gesture's tail is dropped"
1842 );
1843 assert!(matches!(s.overlay, Overlay::ConfirmSave { .. }));
1844
1845 settle_overlay(&mut s);
1846 let (tx, _rx) = unbounded_channel();
1847 s.handle_input(&crate::test_support::mouse_down_at(0, 0), &tx);
1848 assert!(matches!(s.overlay, Overlay::None), "outside press cancels");
1849 }
1850
1851 #[test]
1852 fn hovering_a_control_highlights_it() {
1853 let mut s = screen();
1854 let buf = draw(&mut s);
1855 let (x, y) = crate::test_support::find_text(&buf, "[n] New").unwrap();
1856 let (tx, _rx) = unbounded_channel();
1857 s.handle_input(
1858 &InputEvent::Mouse(ratatui::crossterm::event::MouseEvent {
1859 kind: MouseEventKind::Moved,
1860 column: x + 1,
1861 row: y,
1862 modifiers: KeyModifiers::NONE,
1863 }),
1864 &tx,
1865 );
1866 let buf = draw(&mut s);
1867 assert_eq!(buf[(x, y)].bg, s.theme.hover().bg.unwrap());
1868 }
1869}