Skip to main content

kimun_notes/app_screen/
preferences.rs

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// ── Overlay types ─────────────────────────────────────────────────────────────
40
41#[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    /// Vault was rejected due to structural errors (e.g. case conflicts).
64    /// Rendered like the other confirmation dialogs but with a single close button.
65    VaultConflict(String),
66}
67
68// ── Section / Focus enums ─────────────────────────────────────────────────────
69
70#[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
87// ── PreferencesScreen ────────────────────────────────────────────────────────────
88
89pub 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    /// Last mouse position, for the hover highlight (`components::clickable`).
107    pointer: Option<Position>,
108    /// Section-list rows from the last render (inside its border).
109    sidebar_rows: Rect,
110    /// The section content area from the last render.
111    content_rect: Rect,
112    /// The footer's `[Esc] Save & Close  [Tab] …` chips.
113    footer: HintRow,
114    /// The open overlay's outer rect from the last render; a press outside
115    /// it cancels, like Esc.
116    overlay_rect: Rect,
117    /// Drops the tail of the double-click that opened an overlay (see
118    /// [`OpenGuard`](crate::components::clickable::OpenGuard)).
119    /// `shown_overlay` is the kind drawn last frame: a change of kind — one
120    /// overlay replacing another included — is an open.
121    open_guard: crate::components::clickable::OpenGuard,
122    shown_overlay: Option<std::mem::Discriminant<Overlay>>,
123    /// The folder picker's list rows and chips from the last render.
124    browser_rows: Rect,
125    browser_hints: HintRow,
126    /// The Full Reindex confirmation's `[ Cancel ] [ Confirm ]`.
127    reindex_buttons: ButtonRow,
128    /// The Save confirmation's `[ Save ] [ Discard ]`.
129    save_buttons: ButtonRow,
130}
131
132impl PreferencesScreen {
133    pub fn new(settings: SharedSettings) -> Self {
134        let s = settings.read().unwrap();
135        // Build the theme list once and resolve the active theme from it;
136        // `get_theme()` would rebuild the entire list (plus disk IO) again.
137        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    /// Creates a settings screen with a `Failed` error overlay pre-populated.
214    /// Used when the vault was rejected due to structural conflicts.
215    ///
216    /// The `settings` passed in should already have the workspace cleared —
217    /// this is handled by the `VaultConflict` branch in `app::handle_app_message`
218    /// before calling `switch_screen`.
219    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                        // Throwaway vault with its own pool — closing it does
255                        // not touch the app's vault, and leaving it to drop
256                        // keeps a second handle on the cache file open.
257                        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    /// Called when the file browser confirms a directory path (via 'c' or Ctrl+Enter).
278    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            // Browsing path for the selected workspace.
300            {
301                let mut s = self.settings.write().unwrap();
302                // Repoints the selected entry and flags a reindex when that
303                // actually moves the vault — one call, so the two cannot
304                // disagree the way they did when the reindex flag hung off a
305                // separate legacy field.
306                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    /// Write the current section's edited values into the shared settings
324    /// (and, for Appearance, preview the theme). One place for keys and
325    /// clicks, so a click that only moves a selection — a theme row —
326    /// previews exactly like the arrow key does. Workspaces and Indexing
327    /// act through their own key flows and have nothing to sync here.
328    fn sync_section(&mut self) {
329        use crate::settings::workspace_config::WorkspaceConfig;
330        match self.section {
331            PreferencesSection::Appearance => {
332                // Take the theme straight from the section's already-loaded
333                // list: `get_theme()` would re-read custom themes from disk
334                // and rebuild every built-in on each step.
335                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    /// Run `code` through the key path, exactly as if it had been pressed.
378    fn press(&mut self, key: KeyEvent, tx: &AppTx) -> EventState {
379        self.handle_input(&InputEvent::Key(key), tx)
380    }
381
382    /// Every click is turned into the key it stands for and run through
383    /// [`AppScreen::handle_input`] — settings write-back and the workspace
384    /// create/rename/delete flows included — so a click can never do
385    /// something the key would not.
386    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            // The second press of the double-click that opened the overlay
390            // is not aimed at it.
391            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        // The section list: a row picks that section.
404        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            // A click on the field just focuses it; typing edits it.
425            PreferencesSection::Server => None,
426        };
427        // A key runs through the key path, which syncs settings itself. The
428        // one selection-only click that changes a setting is a theme row
429        // (the live preview); every other section's selection is just a
430        // cursor, and motion must never write settings.
431        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            // Nothing to click while the reindex runs.
449            Overlay::IndexingProgress(IndexingProgressState::Running { .. }) => {
450                EventState::Consumed
451            }
452            // One button (`[ OK ]`): any press dismisses.
453            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                            // Click selects; clicking the selected folder
475                            // opens it, like Enter.
476                            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            // The two confirmations: a button focuses itself and is pressed
486            // with Enter (the key path does the rest); outside cancels.
487            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
521/// The button line of a confirmation dialog: the fourth inner row (blank,
522/// message, blank, buttons), clipped to the dialog.
523fn 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
530/// The sections in sidebar order.
531const 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// ── AppScreen impl ────────────────────────────────────────────────────────────
542
543#[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        // Route to active overlay first.
556        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                        // Ctrl+Enter confirms the current directory.
582                        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                    // Shift is fine (uppercase jump targets); Ctrl/Alt chords
599                    // must not be mistaken for plain letters.
600                    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                                        // Throwaway vault with its own pool —
652                                        // see the reindex task above.
653                                        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                            // Discard: section edits were written live into
695                            // the shared settings (theme preview, autosave,
696                            // editor_backend) — roll them back so a later
697                            // save_to_disk can't persist discarded choices.
698                            *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; // block all input while running
711                    }
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        // No active overlay — handle global keys.
736        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                            // Live theme preview on every navigation step.
796                            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                            // Capture pre-action state for rename/delete
813                            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                            // Creating: section collected a name and sent OpenFileBrowser.
824                            // The section stays in Creating mode after Enter; store the name
825                            // for when the file browser confirms a path, then reset.
826                            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                                    // Check for duplicate name.
833                                    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                            // Renaming: section was in Renaming, Enter pressed — apply rename.
853                            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                                // Check for duplicate name.
859                                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                                        // Through `rename_workspace`, never by
880                                        // re-keying the map here: it also pins
881                                        // what the index and history files are
882                                        // called, and a rename that skips that
883                                        // step points the workspace at files
884                                        // that were never created — an empty
885                                        // index and a lost history, silently.
886                                        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                            // Delete confirmation: section stays in ConfirmDelete after 'y'.
895                            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                                    // Artifacts are read while the entry still
901                                    // exists — both file names come from its
902                                    // `file_key` — and deleted after the lock
903                                    // is released, since a delete can wait on a
904                                    // handle another process holds. This path
905                                    // used to drop the entry and leave the
906                                    // index and history behind under a key
907                                    // nothing could attribute to a workspace.
908                                    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                // Fast reindex starts immediately (no confirmation overlay) — it is a
978                // low-cost incremental operation unlike full reindex.
979                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                        // Throwaway vault with its own pool — see the reindex
1003                        // task above.
1004                        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        // Each screen starts its frame's click targets empty.
1040        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        // Footer hint — clickable chips.
1062        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        // Sidebar navigation
1077        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        // Content panel
1119        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        // An overlay is modal: what it covers is not clickable, so it
1152        // must not light up on hover either.
1153        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        // has_parent is true for a temp dir, so the ".." entry exists → selected = Some(0)
1349        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        // Only real entries; if none, selection is None
1358        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    /// A host-absolute workspace path from a `/`-separated literal.
1412    ///
1413    /// `do_save` compares workspaces through `resolve_workspace_path`, which
1414    /// drops anything `SystemPath` cannot make absolute — and `/original/path`
1415    /// carries no drive prefix, so on Windows *both* sides resolve to `None`,
1416    /// the save sees no change, and the test asserts on a branch it never
1417    /// entered.
1418    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    /// Render, then left-click the first cell of `text` shifted by `dx`.
1635    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    /// Let an overlay that just opened accept clicks (past the guard that
1645    /// drops the tail of the gesture which opened it).
1646    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        // `  Use Nerd Fonts  [x]` — the checkbox sits 16 cells after the label.
1665        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        // The second `▶` on screen is the backend's.
1707        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    /// The folder picker: a click selects a folder, clicking it again opens
1729    /// it, and `[c] Choose this folder` picks the folder being shown.
1730    #[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    /// Moving the pointer over a section is not an edit: settings stay
1752    /// untouched, so Esc does not ask to save.
1753    #[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    /// The footer's `[Esc]` closes when nothing changed; with a change the
1785    /// save prompt opens, and its `Discard` button rolls the change back.
1786    #[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    /// Only a left click presses a button: a right-click on `Discard` must
1803    /// not throw the changes away.
1804    #[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    /// The second press of the double-click that opened an overlay must not
1828    /// land on it; a press outside a settled overlay cancels it.
1829    #[test]
1830    fn overlay_guard_and_outside_press() {
1831        let mut s = screen();
1832        // A click opened it (the guard arms only for mouse opens).
1833        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}