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