Skip to main content

kimun_notes/components/dialogs/
mod.rs

1pub use create_note_dialog::CreateNoteDialog;
2pub use delete_dialog::DeleteConfirmDialog;
3pub use file_ops_menu::FileOpsMenuDialog;
4pub use help_dialog::HelpDialog;
5pub use move_dialog::MoveDialog;
6pub use pinned_notes_dialog::PinnedNotesDialog;
7pub use properties_dialog::PropertiesDialog;
8pub use quick_note_modal::QuickNoteModal;
9pub use rename_dialog::RenameDialog;
10pub use save_search_dialog::SaveSearchDialog;
11pub use sort_dialog::SortDialog;
12pub use theme_picker::ThemePickerDialog;
13pub use update_dialog::UpdateAvailableDialog;
14pub use workspace_switcher::WorkspaceSwitcherModal;
15
16use std::sync::Arc;
17
18use kimun_core::NoteVault;
19use ratatui::Frame;
20use ratatui::crossterm::event::KeyCode;
21use ratatui::layout::Rect;
22use ratatui::style::{Color, Style};
23use ratatui::widgets::{Block, Borders, Paragraph, Widget};
24
25use crate::components::Component;
26use crate::components::event_state::EventState;
27use crate::components::events::{AppEvent, AppTx, InputEvent, OverlayData, SaveSource, SortTarget};
28use crate::components::hint_row::HintRow;
29use crate::components::overlay::{Overlay, OverlayKind, OverlayMsg};
30use crate::components::sortable::SortState;
31use crate::settings::themes::Theme;
32
33/// Load every property key in the background; arrives as
34/// [`OverlayData::PropertyKeysLoaded`]. A failed read leaves pickers empty.
35pub(crate) fn spawn_property_keys(vault: Arc<NoteVault>, tx: &AppTx) {
36    let tx = tx.clone();
37    tokio::spawn(async move {
38        if let Ok(keys) = vault.property_keys().await {
39            tx.send(AppEvent::OverlayData(OverlayData::PropertyKeysLoaded(keys)))
40                .ok();
41        }
42    });
43}
44
45// ---------------------------------------------------------------------------
46// ValidationState — shared by RenameDialog and MoveDialog
47// ---------------------------------------------------------------------------
48
49/// Tracks the current state of an async name / destination availability check.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum ValidationState {
52    /// No check has been triggered yet (initial state).
53    Idle,
54    /// A check is in progress.
55    Pending,
56    /// The name / destination is available (does not already exist).
57    Available,
58    /// The name / destination is already taken.
59    Taken,
60}
61
62pub mod create_note_dialog;
63pub mod delete_dialog;
64pub mod file_ops_menu;
65pub mod help_dialog;
66pub mod move_dialog;
67pub mod pinned_notes_dialog;
68pub mod properties_dialog;
69pub mod quick_note_modal;
70pub mod rename_dialog;
71pub mod save_search_dialog;
72pub mod sort_dialog;
73pub mod theme_picker;
74pub mod update_dialog;
75pub mod workspace_switcher;
76
77pub enum ActiveDialog {
78    Menu(FileOpsMenuDialog),
79    Delete(DeleteConfirmDialog),
80    Rename(RenameDialog),
81    Move(MoveDialog),
82    CreateNote(CreateNoteDialog),
83    Help(HelpDialog),
84    QuickNote(QuickNoteModal),
85    WorkspaceSwitcher(WorkspaceSwitcherModal),
86    SaveSearch(SaveSearchDialog),
87    Sort(SortDialog),
88    PinnedNotes(PinnedNotesDialog),
89    ThemePicker(ThemePickerDialog),
90    UpdateAvailable(UpdateAvailableDialog),
91    Properties(PropertiesDialog),
92}
93
94impl ActiveDialog {
95    pub fn set_error(&mut self, msg: String) {
96        match self {
97            ActiveDialog::Menu(_) => {} // menu has no error state
98            ActiveDialog::Delete(d) => d.error = Some(msg),
99            ActiveDialog::Rename(d) => d.error = Some(msg),
100            ActiveDialog::Move(d) => d.error = Some(msg),
101            ActiveDialog::CreateNote(d) => d.error = Some(msg),
102            ActiveDialog::Help(_) => {}
103            ActiveDialog::QuickNote(d) => d.error = Some(msg),
104            ActiveDialog::WorkspaceSwitcher(_) => {} // no error state
105            ActiveDialog::SaveSearch(_) => {}        // no error state
106            ActiveDialog::Sort(_) => {}              // no error state
107            ActiveDialog::PinnedNotes(_) => {} // no error state: its own failures arrive as PinnedNotesLoaded(Err) and flash
108            ActiveDialog::ThemePicker(_) => {} // no error state
109            ActiveDialog::UpdateAvailable(_) => {} // no error state
110            ActiveDialog::Properties(d) => d.error = Some(msg),
111        }
112    }
113
114    // Constructors for the dialogs opened by EditorScreen via OverlayHost.
115    pub fn help(
116        key_bindings: &crate::keys::KeyBindings,
117        tree: &crate::keys::leader::LeaderNode,
118    ) -> Self {
119        ActiveDialog::Help(HelpDialog::new(key_bindings, tree))
120    }
121
122    /// The full leader-tree cheatsheet (leader `?`).
123    pub fn cheatsheet(settings: &crate::settings::AppSettings) -> Self {
124        ActiveDialog::Help(HelpDialog::cheatsheet(settings))
125    }
126
127    /// The search query syntax reference (F1 over the Find drawer view).
128    pub fn query_syntax() -> Self {
129        ActiveDialog::Help(HelpDialog::query_syntax())
130    }
131
132    /// The live theme picker (leader `v c`).
133    pub fn theme_picker(settings: &crate::settings::AppSettings) -> Self {
134        ActiveDialog::ThemePicker(ThemePickerDialog::new(settings))
135    }
136
137    /// The update-available dialog.
138    pub fn update(status: &crate::update::UpdateStatus) -> Self {
139        ActiveDialog::UpdateAvailable(UpdateAvailableDialog::new(status))
140    }
141
142    pub fn quick_note(vault: Arc<NoteVault>) -> Self {
143        ActiveDialog::QuickNote(QuickNoteModal::new(vault))
144    }
145
146    pub fn workspace_switcher(settings: &crate::settings::AppSettings) -> Self {
147        ActiveDialog::WorkspaceSwitcher(WorkspaceSwitcherModal::new(settings))
148    }
149
150    pub fn create_note(
151        path: kimun_core::nfs::VaultPath,
152        vault: Arc<NoteVault>,
153        content: Option<String>,
154    ) -> Self {
155        ActiveDialog::CreateNote(CreateNoteDialog::new(path, vault, content))
156    }
157
158    /// Open the save-search dialog. `provenance` is the saved-search name the
159    /// query came from (the breadcrumb), pre-filled as the default name. The
160    /// existing names load in the background and arrive via
161    /// [`OverlayData::SavedSearchNamesLoaded`] to drive the dialog's hint.
162    pub fn save_search(
163        query: String,
164        provenance: Option<String>,
165        source: SaveSource,
166        vault: Arc<NoteVault>,
167        tx: &AppTx,
168    ) -> Self {
169        let tx = tx.clone();
170        tokio::spawn(async move {
171            if let Ok(searches) = vault.list_saved_searches().await {
172                let names = searches.into_iter().map(|s| s.name).collect();
173                tx.send(AppEvent::OverlayData(OverlayData::SavedSearchNamesLoaded(
174                    names,
175                )))
176                .ok();
177            }
178        });
179        ActiveDialog::SaveSearch(SaveSearchDialog::new(query, provenance, source))
180    }
181
182    /// The sort dialog for `target`, opened on its `state` (shown as
183    /// "Unsorted" while `unsorted`). With a `vault`, the property keys load
184    /// in the background for the Key picker (pass one exactly when
185    /// `allows_property`).
186    pub fn sort(
187        target: SortTarget,
188        state: SortState,
189        unsorted: bool,
190        allows_property: bool,
191        vault: Option<Arc<NoteVault>>,
192        tx: &AppTx,
193    ) -> Self {
194        if let Some(vault) = vault {
195            spawn_property_keys(vault, tx);
196        }
197        ActiveDialog::Sort(SortDialog::new(target, state, allows_property).unsorted(unsorted))
198    }
199
200    /// The pinned-notes dialog (leader `f p`). Loads in the background and
201    /// arrives via [`OverlayData::PinnedNotesLoaded`].
202    pub fn pinned_notes(vault: Arc<NoteVault>, tx: &AppTx) -> Self {
203        ActiveDialog::PinnedNotes(PinnedNotesDialog::new(vault, tx))
204    }
205
206    pub fn properties(path: kimun_core::nfs::VaultPath, vault: Arc<NoteVault>, tx: &AppTx) -> Self {
207        ActiveDialog::Properties(PropertiesDialog::new(path, vault, tx))
208    }
209
210    pub fn file_ops_menu(path: kimun_core::nfs::VaultPath) -> Self {
211        ActiveDialog::Menu(FileOpsMenuDialog::new(path))
212    }
213
214    pub fn delete(path: kimun_core::nfs::VaultPath, vault: Arc<NoteVault>) -> Self {
215        ActiveDialog::Delete(DeleteConfirmDialog::new(path, vault))
216    }
217
218    pub fn rename(path: kimun_core::nfs::VaultPath, vault: Arc<NoteVault>) -> Self {
219        ActiveDialog::Rename(RenameDialog::new(path, vault))
220    }
221
222    pub fn move_to(path: kimun_core::nfs::VaultPath, vault: Arc<NoteVault>, tx: &AppTx) -> Self {
223        ActiveDialog::Move(MoveDialog::new(path, vault, tx))
224    }
225}
226
227impl Overlay for ActiveDialog {
228    fn kind(&self) -> OverlayKind {
229        OverlayKind::Dialog
230    }
231
232    fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
233        <Self as Component>::handle_input(self, event, tx)
234    }
235
236    fn handle_data(
237        &mut self,
238        data: &OverlayData,
239        _vault: &Arc<NoteVault>,
240        tx: &AppTx,
241    ) -> OverlayMsg {
242        match data {
243            OverlayData::RenameValidation { available } => {
244                if let ActiveDialog::Rename(d) = self {
245                    d.validation_state = if *available {
246                        ValidationState::Available
247                    } else {
248                        ValidationState::Taken
249                    };
250                    d.validation_task = None;
251                }
252                OverlayMsg::Consumed
253            }
254            OverlayData::PropertyKeysLoaded(keys) => {
255                match self {
256                    ActiveDialog::Sort(d) => d.set_keys(keys.clone()),
257                    ActiveDialog::Properties(d) => d.set_keys(keys.clone()),
258                    _ => {}
259                }
260                OverlayMsg::Consumed
261            }
262            OverlayData::PropertiesLoaded { path, result } => {
263                if let ActiveDialog::Properties(d) = self {
264                    d.on_loaded(path, result);
265                }
266                OverlayMsg::Consumed
267            }
268            OverlayData::PropertyWritten { path, result } => {
269                if let ActiveDialog::Properties(d) = self {
270                    d.on_written(path, result, tx);
271                }
272                OverlayMsg::Consumed
273            }
274            OverlayData::MoveDirectoriesLoaded(paths) => {
275                if let ActiveDialog::Move(d) = self {
276                    d.all_dirs = paths.clone();
277                    d.filtered = None;
278                    d.load_task = None;
279                    if d.list_state.selected().is_none() && !d.results().is_empty() {
280                        d.list_state.select(Some(0));
281                    }
282                    d.spawn_validation(tx);
283                }
284                OverlayMsg::Consumed
285            }
286            OverlayData::MoveFilterResults(paths) => {
287                if let ActiveDialog::Move(d) = self {
288                    d.filter_task = None;
289                    d.filtered = Some(paths.clone());
290                    if !d.results().is_empty() {
291                        d.list_state.select(Some(0));
292                    } else {
293                        d.list_state.select(None);
294                    }
295                    d.spawn_validation(tx);
296                }
297                OverlayMsg::Consumed
298            }
299            OverlayData::MoveDestValidation { available } => {
300                if let ActiveDialog::Move(d) = self {
301                    d.dest_validation = if *available {
302                        ValidationState::Available
303                    } else {
304                        ValidationState::Taken
305                    };
306                    d.validation_task = None;
307                }
308                OverlayMsg::Consumed
309            }
310            OverlayData::SavedSearchNamesLoaded(names) => {
311                if let ActiveDialog::SaveSearch(d) = self {
312                    d.set_existing_names(names.clone());
313                }
314                OverlayMsg::Consumed
315            }
316            OverlayData::PinnedNotesLoaded(result) => {
317                if let ActiveDialog::PinnedNotes(d) = self {
318                    d.handle_loaded(result, tx);
319                }
320                OverlayMsg::Consumed
321            }
322            OverlayData::Error(text) => {
323                self.set_error(text.clone());
324                OverlayMsg::Consumed
325            }
326        }
327    }
328
329    fn render(&mut self, f: &mut Frame, area: Rect, theme: &Theme) {
330        <Self as Component>::render(self, f, area, theme, true);
331    }
332}
333
334impl Component for ActiveDialog {
335    fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
336        let key = match event {
337            InputEvent::Key(key) => key,
338            // Exhaustive on purpose: a new dialog must decide what the mouse
339            // does in it. Each one consumes every mouse event — it is modal,
340            // and a click must never reach the panels behind it.
341            InputEvent::Mouse(m) => {
342                return match self {
343                    ActiveDialog::Sort(d) => d.handle_mouse(m, tx),
344                    ActiveDialog::Properties(d) => d.handle_mouse(m, tx),
345                    ActiveDialog::Delete(d) => d.handle_mouse(m, tx),
346                    ActiveDialog::Menu(d) => d.handle_mouse(m, tx),
347                    ActiveDialog::Rename(d) => d.handle_mouse(m, tx),
348                    ActiveDialog::Move(d) => d.handle_mouse(m, tx),
349                    ActiveDialog::SaveSearch(d) => d.handle_input(event, tx),
350                    ActiveDialog::CreateNote(d) => d.handle_mouse(m, tx),
351                    ActiveDialog::QuickNote(d) => d.handle_mouse(m, tx),
352                    ActiveDialog::WorkspaceSwitcher(d) => d.handle_mouse(m, tx),
353                    ActiveDialog::UpdateAvailable(d) => d.handle_mouse(m, tx),
354                    ActiveDialog::ThemePicker(d) => d.handle_mouse(m, tx),
355                    ActiveDialog::Help(d) => d.handle_mouse(m, tx),
356                    ActiveDialog::PinnedNotes(d) => d.handle_mouse(m, tx),
357                };
358            }
359            InputEvent::Paste(_) => return EventState::NotConsumed,
360        };
361        match self {
362            ActiveDialog::Menu(d) => d.handle_key(*key, tx),
363            ActiveDialog::Delete(d) => d.handle_key(*key, tx),
364            ActiveDialog::Rename(d) => d.handle_key(*key, tx),
365            ActiveDialog::Move(d) => d.handle_key(*key, tx),
366            ActiveDialog::CreateNote(d) => d.handle_key(*key, tx),
367            ActiveDialog::Help(d) => d.handle_key(*key, tx),
368            ActiveDialog::QuickNote(d) => d.handle_key(*key, tx),
369            ActiveDialog::WorkspaceSwitcher(d) => d.handle_key(*key, tx),
370            ActiveDialog::SaveSearch(d) => d.handle_input(event, tx),
371            ActiveDialog::Sort(d) => d.handle_input(event, tx),
372            ActiveDialog::PinnedNotes(d) => d.handle_input(event, tx),
373            ActiveDialog::ThemePicker(d) => d.handle_key(*key, tx),
374            ActiveDialog::UpdateAvailable(d) => d.handle_key(*key, tx),
375            ActiveDialog::Properties(d) => d.handle_key(*key, tx),
376        }
377    }
378
379    fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
380        match self {
381            ActiveDialog::Menu(d) => d.render(f, rect, theme, focused),
382            ActiveDialog::Delete(d) => d.render(f, rect, theme, focused),
383            ActiveDialog::Rename(d) => d.render(f, rect, theme, focused),
384            ActiveDialog::Move(d) => d.render(f, rect, theme, focused),
385            ActiveDialog::CreateNote(d) => d.render(f, rect, theme, focused),
386            ActiveDialog::Help(d) => d.render(f, rect, theme, focused),
387            ActiveDialog::QuickNote(d) => d.render(f, rect, theme, focused),
388            ActiveDialog::WorkspaceSwitcher(d) => d.render(f, rect, theme, focused),
389            ActiveDialog::SaveSearch(d) => d.render(f, rect, theme, focused),
390            ActiveDialog::Sort(d) => d.render(f, rect, theme, focused),
391            ActiveDialog::PinnedNotes(d) => d.render(f, rect, theme, focused),
392            ActiveDialog::ThemePicker(d) => d.render(f, rect, theme, focused),
393            ActiveDialog::UpdateAvailable(d) => d.render(f, rect, theme, focused),
394            ActiveDialog::Properties(d) => d.render(f, rect, theme),
395        }
396    }
397}
398
399// ---------------------------------------------------------------------------
400// Shared render helpers
401// ---------------------------------------------------------------------------
402
403/// Renders a pre-computed path string (should already include leading spaces).
404pub(super) fn render_path_row(f: &mut Frame, rect: Rect, path: &str, fg: Color, bg: Color) {
405    f.render_widget(
406        Paragraph::new(path).style(Style::default().fg(fg).bg(bg)),
407        rect,
408    );
409}
410
411/// Renders a single-line horizontal rule (TOP border only).
412pub(super) fn render_separator(f: &mut Frame, rect: Rect, gray: Color, bg: Color) {
413    Block::default()
414        .borders(Borders::TOP)
415        .border_style(Style::default().fg(gray))
416        .style(Style::default().bg(bg))
417        .render(rect, f.buffer_mut());
418}
419
420/// Renders `  Error: {msg}` in the theme's error color on the panel background.
421pub(super) fn render_error_row(f: &mut Frame, rect: Rect, msg: &str, theme: &Theme) {
422    f.render_widget(
423        Paragraph::new(format!("  Error: {msg}")).style(
424            Style::default()
425                .fg(theme.red.to_ratatui())
426                .bg(theme.bg_panel.to_ratatui()),
427        ),
428        rect,
429    );
430}
431
432/// The `[Enter] {action}   [Esc] Cancel` row shared by the form dialogs.
433/// Each chip is clickable and runs its key.
434pub(super) fn confirm_hints(action: &str) -> HintRow {
435    HintRow::new(&[
436        (KeyCode::Enter, "Enter", action),
437        (KeyCode::Esc, "Esc", "Cancel"),
438    ])
439    .with_gap(3)
440}
441
442/// Draw a [`confirm_hints`] row. `enter_active` is only the *look*: `fg`
443/// when ready, gray otherwise. `enter_clickable` must mirror exactly when the
444/// Enter key acts — a dialog whose Enter still runs while it looks not-ready
445/// (Move before validation lands, Save search while names load) passes
446/// `true`, so the click never does less than the key.
447pub(super) fn render_confirm_hints(
448    f: &mut Frame,
449    rect: Rect,
450    hints: &mut HintRow,
451    enter_active: bool,
452    enter_clickable: bool,
453    fg: Color,
454    theme: &Theme,
455) {
456    let gray = theme.gray.to_ratatui();
457    let bg = theme.bg_panel.to_ratatui();
458    hints.set_enabled(0, enter_clickable);
459    hints.set_style(
460        0,
461        Some(if enter_active {
462            Style::default().fg(fg).bg(bg)
463        } else {
464            Style::default().fg(gray).bg(bg)
465        }),
466    );
467    hints.render(f, rect, Style::default().fg(gray).bg(bg), theme);
468}
469
470// ---------------------------------------------------------------------------
471// Layout helper
472// ---------------------------------------------------------------------------
473
474/// Centre a dialog of exactly `width` × `height` characters.
475pub(super) use crate::components::fixed_centered_rect;
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480    use crate::keys::KeyBindings;
481
482    /// Render `dialog` on a 100×40 screen, left-click the first cell of
483    /// `text`, and return the events the click sent. Panics if `text` is not
484    /// drawn — a chip that moved off-screen should fail loudly.
485    pub(crate) fn click_text(dialog: &mut ActiveDialog, text: &str) -> Vec<AppEvent> {
486        let (col, row) = draw_and_find(dialog, text);
487        click_at(dialog, col, row)
488    }
489
490    /// Render `dialog` and return the cell where `text` starts.
491    pub(crate) fn draw_and_find(dialog: &mut ActiveDialog, text: &str) -> (u16, u16) {
492        use ratatui::{Terminal, backend::TestBackend};
493        let theme = Theme::gruvbox_dark();
494        let mut t = Terminal::new(TestBackend::new(100, 40)).unwrap();
495        t.draw(|f| <ActiveDialog as Component>::render(dialog, f, f.area(), &theme, true))
496            .unwrap();
497        crate::test_support::find_text(t.backend().buffer(), text)
498            .unwrap_or_else(|| panic!("{text:?} not drawn"))
499    }
500
501    /// Left-click (col,row) and return the events sent.
502    pub(crate) fn click_at(dialog: &mut ActiveDialog, col: u16, row: u16) -> Vec<AppEvent> {
503        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
504        Overlay::handle_input(dialog, &crate::test_support::mouse_down_at(col, row), &tx);
505        crate::test_support::drain(&mut rx)
506    }
507
508    fn closed(events: &[AppEvent]) -> bool {
509        events.iter().any(|e| matches!(e, AppEvent::CloseOverlay))
510    }
511
512    fn has(events: &[AppEvent], pred: impl Fn(&AppEvent) -> bool) -> bool {
513        events.iter().any(pred)
514    }
515
516    #[test]
517    fn menu_actions_are_clickable() {
518        use crate::components::events::FileOp;
519        let path = kimun_core::nfs::VaultPath::new("a.md");
520        let mut d = ActiveDialog::Menu(FileOpsMenuDialog::new(path));
521        assert!(has(&click_text(&mut d, "[D]"), |e| matches!(
522            e,
523            AppEvent::FileOp(FileOp::ShowDelete(_))
524        )));
525        assert!(has(&click_text(&mut d, "Rename"), |e| matches!(
526            e,
527            AppEvent::FileOp(FileOp::ShowRename(_))
528        )));
529        assert!(has(&click_text(&mut d, "Move"), |e| matches!(
530            e,
531            AppEvent::FileOp(FileOp::ShowMove(_))
532        )));
533        assert!(closed(&click_text(&mut d, "[Esc] Cancel")));
534        assert!(closed(&click_at(&mut d, 0, 0)), "outside press cancels");
535        assert!(
536            !closed(&click_text(&mut d, "a.md")),
537            "inside press does not"
538        );
539    }
540
541    #[tokio::test]
542    async fn rename_chips_are_clickable() {
543        let vault = crate::test_support::temp_vault("dlg-rename-click").await;
544        let mut d = ActiveDialog::Rename(RenameDialog::new(
545            kimun_core::nfs::VaultPath::new("a.md"),
546            vault,
547        ));
548        // Enter is inert until the name validates, like the key.
549        assert!(click_text(&mut d, "[Enter] Rename").is_empty());
550        assert!(closed(&click_text(&mut d, "[Esc] Cancel")));
551    }
552
553    #[tokio::test]
554    async fn move_list_rows_select_and_cancel_chip_closes() {
555        let vault = crate::test_support::temp_vault("dlg-move-click").await;
556        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
557        let mut m = MoveDialog::new(kimun_core::nfs::VaultPath::new("a.md"), vault, &tx);
558        m.all_dirs = vec![
559            kimun_core::nfs::VaultPath::root(),
560            kimun_core::nfs::VaultPath::new("projects"),
561        ];
562        m.list_state.select(Some(0));
563        let mut d = ActiveDialog::Move(m);
564        click_text(&mut d, "projects");
565        let ActiveDialog::Move(m) = &d else {
566            unreachable!()
567        };
568        assert_eq!(m.list_state.selected(), Some(1), "click selects the row");
569        assert!(closed(&click_text(&mut d, "[Esc] Cancel")));
570    }
571
572    /// The Enter key moves while validation is still Idle/Pending, so the
573    /// chip must too — a click may never do less than the key.
574    #[tokio::test]
575    async fn move_enter_chip_acts_before_validation_lands() {
576        let vault = crate::test_support::temp_vault("dlg-move-enter").await;
577        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
578        let mut m = MoveDialog::new(kimun_core::nfs::VaultPath::new("a.md"), vault, &tx);
579        m.all_dirs = vec![kimun_core::nfs::VaultPath::new("projects")];
580        m.list_state.select(Some(0));
581        assert_eq!(m.dest_validation, ValidationState::Idle);
582        let mut d = ActiveDialog::Move(m);
583        let (col, row) = draw_and_find(&mut d, "[Enter] Move here");
584        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
585        Overlay::handle_input(&mut d, &crate::test_support::mouse_down_at(col, row), &tx);
586        // The move itself runs on a task; `a.md` does not exist, so it
587        // reports an error — proof the click reached the move.
588        let ev = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
589            .await
590            .expect("the click started the move");
591        assert!(matches!(
592            ev,
593            Some(AppEvent::OverlayData(OverlayData::Error(_))) | Some(AppEvent::FileOp(_))
594        ));
595    }
596
597    #[test]
598    fn save_search_chips_are_clickable() {
599        use crate::components::events::SavedSearchFlow;
600        let mut d = ActiveDialog::SaveSearch(SaveSearchDialog::new(
601            "#tag".into(),
602            None,
603            SaveSource::QueryPanel,
604        ));
605        if let ActiveDialog::SaveSearch(s) = &mut d {
606            s.set_existing_names(vec![]);
607        }
608        let ev = click_text(&mut d, "[Enter] Save new");
609        assert!(has(&ev, |e| matches!(
610            e,
611            AppEvent::SavedSearch(SavedSearchFlow::Confirmed { .. })
612        )));
613        assert!(closed(&click_text(&mut d, "[Esc] Cancel")));
614    }
615
616    #[tokio::test]
617    async fn create_note_chips_are_clickable() {
618        let vault = crate::test_support::temp_vault("dlg-create-click").await;
619        let mut d = ActiveDialog::CreateNote(CreateNoteDialog::new(
620            kimun_core::nfs::VaultPath::new("new.md"),
621            vault,
622            None,
623        ));
624        assert!(closed(&click_text(&mut d, "[Esc] Cancel")));
625    }
626
627    #[tokio::test]
628    async fn quick_note_chips_are_clickable() {
629        let vault = crate::test_support::temp_vault("dlg-quick-click").await;
630        let mut d = ActiveDialog::QuickNote(QuickNoteModal::new(vault));
631        // Empty input: Enter closes, same as the key.
632        assert!(closed(&click_text(&mut d, "[Enter] Save")));
633        assert!(closed(&click_text(&mut d, "[Shift+Enter]")));
634        assert!(closed(&click_text(&mut d, "[Esc] Cancel")));
635    }
636
637    #[test]
638    fn workspace_switcher_rows_and_outside_click() {
639        let mut d = ActiveDialog::WorkspaceSwitcher(WorkspaceSwitcherModal::with_workspaces(vec![
640            ("alpha".into(), true),
641            ("beta".into(), false),
642        ]));
643        assert!(click_text(&mut d, "beta").is_empty(), "first click selects");
644        let ev = click_text(&mut d, "beta");
645        assert!(
646            has(
647                &ev,
648                |e| matches!(e, AppEvent::WorkspaceSwitched(n) if n == "beta")
649            ),
650            "clicking the selected row switches"
651        );
652        assert!(closed(&click_text(&mut d, "[Esc] Cancel")));
653        assert!(closed(&click_at(&mut d, 0, 0)), "outside press cancels");
654    }
655
656    #[test]
657    fn update_actions_are_clickable() {
658        use crate::components::events::UpdateFlow;
659        let status = crate::update::UpdateStatus {
660            current: "0.1.0".into(),
661            latest: "9.9.9".into(),
662            channel: crate::update::InstallChannel::Script,
663            update_available: true,
664            dismissed: false,
665        };
666        let mut d = ActiveDialog::update(&status);
667        assert!(has(&click_text(&mut d, "Update now"), |e| matches!(
668            e,
669            AppEvent::Update(UpdateFlow::Apply)
670        )));
671        assert!(has(&click_text(&mut d, "[S]"), |e| matches!(
672            e,
673            AppEvent::Update(UpdateFlow::Dismiss(_))
674        )));
675        assert!(closed(&click_text(&mut d, "[Esc] Close")));
676        assert!(closed(&click_at(&mut d, 0, 0)), "outside press closes");
677        assert!(
678            !closed(&click_text(&mut d, "Releases:")),
679            "inside press does not"
680        );
681    }
682
683    #[test]
684    fn theme_picker_click_previews_then_keeps() {
685        let settings = crate::settings::AppSettings::default();
686        let mut d = ActiveDialog::theme_picker(&settings);
687        // Any theme other than the current one.
688        let name = {
689            let current = settings.effective_theme_name();
690            settings
691                .theme_list()
692                .into_iter()
693                .map(|t| t.name)
694                .find(|n| *n != current)
695                .expect("more than one theme")
696        };
697        let ev = click_text(&mut d, &name);
698        assert!(has(&ev, |e| matches!(
699            e,
700            AppEvent::ApplyTheme { persist: false, .. }
701        )));
702        assert!(!closed(&ev), "first click only previews");
703        let ev = click_text(&mut d, &name);
704        assert!(has(&ev, |e| matches!(
705            e,
706            AppEvent::ApplyTheme { persist: true, .. }
707        )));
708        assert!(closed(&ev), "clicking the previewed theme keeps it");
709    }
710
711    #[test]
712    fn theme_picker_outside_click_reverts() {
713        let settings = crate::settings::AppSettings::default();
714        let mut d = ActiveDialog::theme_picker(&settings);
715        let name = {
716            let current = settings.effective_theme_name();
717            settings
718                .theme_list()
719                .into_iter()
720                .map(|t| t.name)
721                .find(|n| *n != current)
722                .expect("more than one theme")
723        };
724        click_text(&mut d, &name);
725        let ev = click_at(&mut d, 0, 0);
726        assert!(
727            has(&ev, |e| matches!(
728                e,
729                AppEvent::ApplyTheme { persist: false, .. }
730            )),
731            "reverts the preview"
732        );
733        assert!(closed(&ev));
734    }
735
736    #[test]
737    fn help_close_chip_and_outside_click_close() {
738        let mut d = ActiveDialog::cheatsheet(&crate::settings::AppSettings::default());
739        assert!(closed(&click_text(&mut d, "[Esc] Close")));
740        assert!(
741            !closed(&click_text(&mut d, "Scroll")),
742            "the scroll hint is inert"
743        );
744        assert!(closed(&click_at(&mut d, 0, 0)));
745    }
746
747    #[test]
748    fn help_wheel_scrolls() {
749        use ratatui::crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
750        let mut d = ActiveDialog::cheatsheet(&crate::settings::AppSettings::default());
751        let (col, row) = draw_and_find(&mut d, "[Esc] Close");
752        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
753        Overlay::handle_input(
754            &mut d,
755            &InputEvent::Mouse(MouseEvent {
756                kind: MouseEventKind::ScrollDown,
757                column: col,
758                row,
759                modifiers: KeyModifiers::NONE,
760            }),
761            &tx,
762        );
763        let ActiveDialog::Help(h) = &d else {
764            unreachable!()
765        };
766        assert!(h.scroll() > 0);
767    }
768
769    #[tokio::test]
770    async fn pinned_rows_and_chips_are_clickable() {
771        use crate::components::events::PinnedRow;
772        use kimun_core::nfs::VaultPath;
773        let vault = crate::test_support::temp_vault("dlg-pinned-click").await;
774        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
775        let mut p = PinnedNotesDialog::new(vault, &tx);
776        p.set_rows(vec![
777            PinnedRow {
778                path: VaultPath::new("alpha.md"),
779                missing: false,
780            },
781            PinnedRow {
782                path: VaultPath::new("beta.md"),
783                missing: false,
784            },
785        ]);
786        let mut d = ActiveDialog::PinnedNotes(p);
787        assert!(
788            click_text(&mut d, "beta.md").is_empty(),
789            "first click selects"
790        );
791        let ActiveDialog::PinnedNotes(p) = &d else {
792            unreachable!()
793        };
794        assert_eq!(p.selected, 1);
795        assert!(
796            !click_text(&mut d, "beta.md").is_empty(),
797            "second click opens"
798        );
799        assert!(closed(&click_text(&mut d, "[Esc] Close")));
800        assert!(closed(&click_at(&mut d, 0, 0)));
801    }
802
803    fn sidebar_sort() -> ActiveDialog {
804        use crate::components::file_list::{SortField, SortOrder};
805        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
806        let state = SortState {
807            field: SortField::Name,
808            order: SortOrder::Ascending,
809            group_dirs: Some(false),
810        };
811        ActiveDialog::sort(SortTarget::Sidebar, state, false, false, None, &tx)
812    }
813
814    #[test]
815    fn sort_footer_chips_save_and_close() {
816        let mut d = sidebar_sort();
817        assert!(has(&click_text(&mut d, "[s] Save default"), |e| matches!(
818            e,
819            AppEvent::SortChanged { persist: true, .. }
820        )));
821        assert!(closed(&click_text(&mut d, "[Esc] Close")));
822        assert!(
823            click_text(&mut d, "[Space] Toggle").is_empty(),
824            "informational chips are inert"
825        );
826    }
827
828    #[tokio::test]
829    async fn delete_cancel_chip_closes() {
830        let vault = crate::test_support::temp_vault("dlg-delete-click").await;
831        let mut d = ActiveDialog::Delete(DeleteConfirmDialog::new(
832            kimun_core::nfs::VaultPath::new("a.md"),
833            vault,
834        ));
835        assert!(closed(&click_text(&mut d, "[Esc] Cancel")));
836        assert!(
837            !closed(&click_text(&mut d, "This cannot")),
838            "body text is inert"
839        );
840    }
841
842    #[test]
843    fn active_dialog_help_variant_compiles() {
844        let dialog = HelpDialog::new(&KeyBindings::empty(), &crate::keys::leader::leader_tree());
845        let _active: ActiveDialog = ActiveDialog::Help(dialog);
846    }
847
848    #[test]
849    fn active_dialog_sort_variant_compiles() {
850        use crate::components::events::SortTarget;
851        use crate::components::file_list::{SortField, SortOrder};
852        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
853        let state = crate::components::sortable::SortState {
854            field: SortField::Name,
855            order: SortOrder::Ascending,
856            group_dirs: Some(false),
857        };
858        let _active: ActiveDialog =
859            ActiveDialog::sort(SortTarget::Sidebar, state, false, false, None, &tx);
860    }
861
862    /// Every dialog test for `PinnedNotesDialog` calls `set_rows` directly,
863    /// bypassing the routing this module owns. A regression here (e.g. the
864    /// `PinnedNotesLoaded` arm losing its `if let` guard, or matching the
865    /// wrong variant) would leave the dialog permanently empty with none of
866    /// those tests failing — so this drives the real `handle_data` path.
867    #[tokio::test]
868    async fn active_dialog_routes_pinned_notes_loaded_rows() {
869        use crate::components::events::PinnedRow;
870        use kimun_core::nfs::VaultPath;
871        use tokio::sync::mpsc::unbounded_channel;
872
873        let vault = crate::test_support::temp_vault("active-dialog-pinned").await;
874        let (tx, _rx) = unbounded_channel();
875        let mut active = ActiveDialog::pinned_notes(vault.clone(), &tx);
876
877        let rows = vec![PinnedRow {
878            path: VaultPath::new("a.md"),
879            missing: false,
880        }];
881        let msg = active.handle_data(
882            &OverlayData::PinnedNotesLoaded(Ok(rows.clone())),
883            &vault,
884            &tx,
885        );
886        assert!(matches!(msg, OverlayMsg::Consumed));
887
888        match &active {
889            ActiveDialog::PinnedNotes(d) => assert_eq!(d.rows(), rows.as_slice()),
890            _ => panic!("expected ActiveDialog::PinnedNotes"),
891        }
892    }
893
894    /// A foreign `OverlayData::Error` — a rename or paste task failing after
895    /// its own dialog closed — must not be routed into the pinned-notes
896    /// dialog as if it were the reload it is waiting on: that would clear
897    /// its in-flight guard and reopen the overlapping-write race.
898    #[tokio::test]
899    async fn active_dialog_leaves_pinned_notes_alone_on_a_foreign_error() {
900        use tokio::sync::mpsc::unbounded_channel;
901
902        let vault = crate::test_support::temp_vault("active-dialog-pinned-err").await;
903        let (tx, mut rx) = unbounded_channel();
904        let mut active = ActiveDialog::pinned_notes(vault.clone(), &tx);
905
906        let msg = active.handle_data(
907            &OverlayData::Error("rename failed".to_string()),
908            &vault,
909            &tx,
910        );
911        assert!(matches!(msg, OverlayMsg::Consumed));
912        assert!(
913            rx.try_recv().is_err(),
914            "a foreign error must not be flashed as a pinned-notes failure"
915        );
916        match &active {
917            ActiveDialog::PinnedNotes(d) => assert!(
918                !d.is_loaded(),
919                "a foreign error must not settle the dialog's own load"
920            ),
921            _ => panic!("expected ActiveDialog::PinnedNotes"),
922        }
923    }
924}