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