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 quick_note_modal::QuickNoteModal;
8pub use rename_dialog::RenameDialog;
9pub use save_search_dialog::SaveSearchDialog;
10pub use sort_dialog::SortDialog;
11pub use theme_picker::ThemePickerDialog;
12pub use update_dialog::UpdateAvailableDialog;
13pub use workspace_switcher::WorkspaceSwitcherModal;
14
15use std::sync::Arc;
16
17use kimun_core::NoteVault;
18use ratatui::Frame;
19use ratatui::layout::{Constraint, Direction, Layout, Rect};
20use ratatui::style::{Color, Modifier, Style};
21use ratatui::widgets::{Block, Borders, Paragraph, Widget};
22
23use crate::components::Component;
24use crate::components::event_state::EventState;
25use crate::components::events::{AppEvent, AppTx, InputEvent, OverlayData, SaveSource, SortTarget};
26use crate::components::file_list::{SortField, SortOrder};
27use crate::components::overlay::{Overlay, OverlayKind, OverlayMsg};
28use crate::settings::themes::Theme;
29
30// ---------------------------------------------------------------------------
31// ValidationState — shared by RenameDialog and MoveDialog
32// ---------------------------------------------------------------------------
33
34/// Tracks the current state of an async name / destination availability check.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum ValidationState {
37    /// No check has been triggered yet (initial state).
38    Idle,
39    /// A check is in progress.
40    Pending,
41    /// The name / destination is available (does not already exist).
42    Available,
43    /// The name / destination is already taken.
44    Taken,
45}
46
47pub mod create_note_dialog;
48pub mod delete_dialog;
49pub mod file_ops_menu;
50pub mod help_dialog;
51pub mod move_dialog;
52pub mod pinned_notes_dialog;
53pub mod quick_note_modal;
54pub mod rename_dialog;
55pub mod save_search_dialog;
56pub mod sort_dialog;
57pub mod theme_picker;
58pub mod update_dialog;
59pub mod workspace_switcher;
60
61pub enum ActiveDialog {
62    Menu(FileOpsMenuDialog),
63    Delete(DeleteConfirmDialog),
64    Rename(RenameDialog),
65    Move(MoveDialog),
66    CreateNote(CreateNoteDialog),
67    Help(HelpDialog),
68    QuickNote(QuickNoteModal),
69    WorkspaceSwitcher(WorkspaceSwitcherModal),
70    SaveSearch(SaveSearchDialog),
71    Sort(SortDialog),
72    PinnedNotes(PinnedNotesDialog),
73    ThemePicker(ThemePickerDialog),
74    UpdateAvailable(UpdateAvailableDialog),
75}
76
77impl ActiveDialog {
78    pub fn set_error(&mut self, msg: String) {
79        match self {
80            ActiveDialog::Menu(_) => {} // menu has no error state
81            ActiveDialog::Delete(d) => d.error = Some(msg),
82            ActiveDialog::Rename(d) => d.error = Some(msg),
83            ActiveDialog::Move(d) => d.error = Some(msg),
84            ActiveDialog::CreateNote(d) => d.error = Some(msg),
85            ActiveDialog::Help(_) => {}
86            ActiveDialog::QuickNote(d) => d.error = Some(msg),
87            ActiveDialog::WorkspaceSwitcher(_) => {} // no error state
88            ActiveDialog::SaveSearch(_) => {}        // no error state
89            ActiveDialog::Sort(_) => {}              // no error state
90            ActiveDialog::PinnedNotes(_) => {} // no error state: its own failures arrive as PinnedNotesLoaded(Err) and flash
91            ActiveDialog::ThemePicker(_) => {} // no error state
92            ActiveDialog::UpdateAvailable(_) => {} // no error state
93        }
94    }
95
96    // Constructors for the dialogs opened by EditorScreen via OverlayHost.
97    pub fn help(key_bindings: &crate::keys::KeyBindings) -> Self {
98        ActiveDialog::Help(HelpDialog::new(key_bindings))
99    }
100
101    /// The full leader-tree cheatsheet (leader `?`).
102    pub fn cheatsheet(settings: &crate::settings::AppSettings) -> Self {
103        ActiveDialog::Help(HelpDialog::cheatsheet(settings))
104    }
105
106    /// The search query syntax reference (F1 over the Find drawer view).
107    pub fn query_syntax() -> Self {
108        ActiveDialog::Help(HelpDialog::query_syntax())
109    }
110
111    /// The live theme picker (leader `v c`).
112    pub fn theme_picker(settings: &crate::settings::AppSettings) -> Self {
113        ActiveDialog::ThemePicker(ThemePickerDialog::new(settings))
114    }
115
116    /// The update-available dialog.
117    pub fn update(status: &crate::update::UpdateStatus) -> Self {
118        ActiveDialog::UpdateAvailable(UpdateAvailableDialog::new(status))
119    }
120
121    pub fn quick_note(vault: Arc<NoteVault>) -> Self {
122        ActiveDialog::QuickNote(QuickNoteModal::new(vault))
123    }
124
125    pub fn workspace_switcher(settings: &crate::settings::AppSettings) -> Self {
126        ActiveDialog::WorkspaceSwitcher(WorkspaceSwitcherModal::new(settings))
127    }
128
129    pub fn create_note(
130        path: kimun_core::nfs::VaultPath,
131        vault: Arc<NoteVault>,
132        content: Option<String>,
133    ) -> Self {
134        ActiveDialog::CreateNote(CreateNoteDialog::new(path, vault, content))
135    }
136
137    /// Open the save-search dialog. `provenance` is the saved-search name the
138    /// query came from (the breadcrumb), pre-filled as the default name. The
139    /// existing names load in the background and arrive via
140    /// [`OverlayData::SavedSearchNamesLoaded`] to drive the dialog's hint.
141    pub fn save_search(
142        query: String,
143        provenance: Option<String>,
144        source: SaveSource,
145        vault: Arc<NoteVault>,
146        tx: &AppTx,
147    ) -> Self {
148        let tx = tx.clone();
149        tokio::spawn(async move {
150            if let Ok(searches) = vault.list_saved_searches().await {
151                let names = searches.into_iter().map(|s| s.name).collect();
152                tx.send(AppEvent::OverlayData(OverlayData::SavedSearchNamesLoaded(
153                    names,
154                )))
155                .ok();
156            }
157        });
158        ActiveDialog::SaveSearch(SaveSearchDialog::new(query, provenance, source))
159    }
160
161    pub fn sort(
162        target: SortTarget,
163        field: SortField,
164        order: SortOrder,
165        group_directories: bool,
166    ) -> Self {
167        ActiveDialog::Sort(SortDialog::new(target, field, order, group_directories))
168    }
169
170    /// The pinned-notes dialog (leader `f p`). Loads in the background and
171    /// arrives via [`OverlayData::PinnedNotesLoaded`].
172    pub fn pinned_notes(vault: Arc<NoteVault>, tx: &AppTx) -> Self {
173        ActiveDialog::PinnedNotes(PinnedNotesDialog::new(vault, tx))
174    }
175
176    pub fn file_ops_menu(path: kimun_core::nfs::VaultPath) -> Self {
177        ActiveDialog::Menu(FileOpsMenuDialog::new(path))
178    }
179
180    pub fn delete(path: kimun_core::nfs::VaultPath, vault: Arc<NoteVault>) -> Self {
181        ActiveDialog::Delete(DeleteConfirmDialog::new(path, vault))
182    }
183
184    pub fn rename(path: kimun_core::nfs::VaultPath, vault: Arc<NoteVault>) -> Self {
185        ActiveDialog::Rename(RenameDialog::new(path, vault))
186    }
187
188    pub fn move_to(path: kimun_core::nfs::VaultPath, vault: Arc<NoteVault>, tx: &AppTx) -> Self {
189        ActiveDialog::Move(MoveDialog::new(path, vault, tx))
190    }
191}
192
193impl Overlay for ActiveDialog {
194    fn kind(&self) -> OverlayKind {
195        OverlayKind::Dialog
196    }
197
198    fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
199        <Self as Component>::handle_input(self, event, tx)
200    }
201
202    fn handle_data(
203        &mut self,
204        data: &OverlayData,
205        _vault: &Arc<NoteVault>,
206        tx: &AppTx,
207    ) -> OverlayMsg {
208        match data {
209            OverlayData::RenameValidation { available } => {
210                if let ActiveDialog::Rename(d) = self {
211                    d.validation_state = if *available {
212                        ValidationState::Available
213                    } else {
214                        ValidationState::Taken
215                    };
216                    d.validation_task = None;
217                }
218                OverlayMsg::Consumed
219            }
220            OverlayData::MoveDirectoriesLoaded(paths) => {
221                if let ActiveDialog::Move(d) = self {
222                    d.all_dirs = paths.clone();
223                    d.filtered = None;
224                    d.load_task = None;
225                    if d.list_state.selected().is_none() && !d.results().is_empty() {
226                        d.list_state.select(Some(0));
227                    }
228                    d.spawn_validation(tx);
229                }
230                OverlayMsg::Consumed
231            }
232            OverlayData::MoveFilterResults(paths) => {
233                if let ActiveDialog::Move(d) = self {
234                    d.filter_task = None;
235                    d.filtered = Some(paths.clone());
236                    if !d.results().is_empty() {
237                        d.list_state.select(Some(0));
238                    } else {
239                        d.list_state.select(None);
240                    }
241                    d.spawn_validation(tx);
242                }
243                OverlayMsg::Consumed
244            }
245            OverlayData::MoveDestValidation { available } => {
246                if let ActiveDialog::Move(d) = self {
247                    d.dest_validation = if *available {
248                        ValidationState::Available
249                    } else {
250                        ValidationState::Taken
251                    };
252                    d.validation_task = None;
253                }
254                OverlayMsg::Consumed
255            }
256            OverlayData::SavedSearchNamesLoaded(names) => {
257                if let ActiveDialog::SaveSearch(d) = self {
258                    d.set_existing_names(names.clone());
259                }
260                OverlayMsg::Consumed
261            }
262            OverlayData::PinnedNotesLoaded(result) => {
263                if let ActiveDialog::PinnedNotes(d) = self {
264                    d.handle_loaded(result, tx);
265                }
266                OverlayMsg::Consumed
267            }
268            OverlayData::Error(text) => {
269                self.set_error(text.clone());
270                OverlayMsg::Consumed
271            }
272        }
273    }
274
275    fn render(&mut self, f: &mut Frame, area: Rect, theme: &Theme) {
276        <Self as Component>::render(self, f, area, theme, true);
277    }
278}
279
280impl Component for ActiveDialog {
281    fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
282        let InputEvent::Key(key) = event else {
283            return EventState::NotConsumed;
284        };
285        match self {
286            ActiveDialog::Menu(d) => d.handle_key(*key, tx),
287            ActiveDialog::Delete(d) => d.handle_key(*key, tx),
288            ActiveDialog::Rename(d) => d.handle_key(*key, tx),
289            ActiveDialog::Move(d) => d.handle_key(*key, tx),
290            ActiveDialog::CreateNote(d) => d.handle_key(*key, tx),
291            ActiveDialog::Help(d) => d.handle_key(*key, tx),
292            ActiveDialog::QuickNote(d) => d.handle_key(*key, tx),
293            ActiveDialog::WorkspaceSwitcher(d) => d.handle_key(*key, tx),
294            ActiveDialog::SaveSearch(d) => d.handle_input(event, tx),
295            ActiveDialog::Sort(d) => d.handle_input(event, tx),
296            ActiveDialog::PinnedNotes(d) => d.handle_input(event, tx),
297            ActiveDialog::ThemePicker(d) => d.handle_key(*key, tx),
298            ActiveDialog::UpdateAvailable(d) => d.handle_key(*key, tx),
299        }
300    }
301
302    fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
303        match self {
304            ActiveDialog::Menu(d) => d.render(f, rect, theme, focused),
305            ActiveDialog::Delete(d) => d.render(f, rect, theme, focused),
306            ActiveDialog::Rename(d) => d.render(f, rect, theme, focused),
307            ActiveDialog::Move(d) => d.render(f, rect, theme, focused),
308            ActiveDialog::CreateNote(d) => d.render(f, rect, theme, focused),
309            ActiveDialog::Help(d) => d.render(f, rect, theme, focused),
310            ActiveDialog::QuickNote(d) => d.render(f, rect, theme, focused),
311            ActiveDialog::WorkspaceSwitcher(d) => d.render(f, rect, theme, focused),
312            ActiveDialog::SaveSearch(d) => d.render(f, rect, theme, focused),
313            ActiveDialog::Sort(d) => d.render(f, rect, theme, focused),
314            ActiveDialog::PinnedNotes(d) => d.render(f, rect, theme, focused),
315            ActiveDialog::ThemePicker(d) => d.render(f, rect, theme, focused),
316            ActiveDialog::UpdateAvailable(d) => d.render(f, rect, theme, focused),
317        }
318    }
319}
320
321// ---------------------------------------------------------------------------
322// Shared render helpers
323// ---------------------------------------------------------------------------
324
325/// Renders a pre-computed path string (should already include leading spaces).
326pub(super) fn render_path_row(f: &mut Frame, rect: Rect, path: &str, fg: Color, bg: Color) {
327    f.render_widget(
328        Paragraph::new(path).style(Style::default().fg(fg).bg(bg)),
329        rect,
330    );
331}
332
333/// Renders a single-line horizontal rule (TOP border only).
334pub(super) fn render_separator(f: &mut Frame, rect: Rect, gray: Color, bg: Color) {
335    Block::default()
336        .borders(Borders::TOP)
337        .border_style(Style::default().fg(gray))
338        .style(Style::default().bg(bg))
339        .render(rect, f.buffer_mut());
340}
341
342/// Renders `  Error: {msg}` in the theme's error color on the panel background.
343pub(super) fn render_error_row(f: &mut Frame, rect: Rect, msg: &str, theme: &Theme) {
344    f.render_widget(
345        Paragraph::new(format!("  Error: {msg}")).style(
346            Style::default()
347                .fg(theme.red.to_ratatui())
348                .bg(theme.bg_panel.to_ratatui()),
349        ),
350        rect,
351    );
352}
353
354/// Renders `{enter_text}  [Esc] Cancel` split into two horizontal columns.
355/// The Enter part is dimmed when `enter_active` is `false`.
356pub(super) fn render_confirm_hint(
357    f: &mut Frame,
358    rect: Rect,
359    enter_text: &str,
360    enter_active: bool,
361    fg: Color,
362    gray: Color,
363    bg: Color,
364) {
365    let enter_style = if enter_active {
366        Style::default().fg(fg).bg(bg)
367    } else {
368        Style::default().fg(gray).bg(bg).add_modifier(Modifier::DIM)
369    };
370    let chunks = Layout::default()
371        .direction(Direction::Horizontal)
372        .constraints([
373            Constraint::Length(enter_text.len() as u16 + 1),
374            Constraint::Min(1),
375        ])
376        .split(rect);
377    f.render_widget(Paragraph::new(enter_text).style(enter_style), chunks[0]);
378    f.render_widget(
379        Paragraph::new("  [Esc] Cancel").style(Style::default().fg(gray).bg(bg)),
380        chunks[1],
381    );
382}
383
384// ---------------------------------------------------------------------------
385// Layout helper
386// ---------------------------------------------------------------------------
387
388/// Centre a dialog of exactly `width` × `height` characters.
389pub(super) use crate::components::fixed_centered_rect;
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394    use crate::keys::KeyBindings;
395
396    #[test]
397    fn active_dialog_help_variant_compiles() {
398        let dialog = HelpDialog::new(&KeyBindings::empty());
399        let _active: ActiveDialog = ActiveDialog::Help(dialog);
400    }
401
402    #[test]
403    fn active_dialog_sort_variant_compiles() {
404        use crate::components::events::SortTarget;
405        use crate::components::file_list::{SortField, SortOrder};
406        let _active: ActiveDialog = ActiveDialog::sort(
407            SortTarget::Sidebar,
408            SortField::Name,
409            SortOrder::Ascending,
410            false,
411        );
412    }
413
414    /// Every dialog test for `PinnedNotesDialog` calls `set_rows` directly,
415    /// bypassing the routing this module owns. A regression here (e.g. the
416    /// `PinnedNotesLoaded` arm losing its `if let` guard, or matching the
417    /// wrong variant) would leave the dialog permanently empty with none of
418    /// those tests failing — so this drives the real `handle_data` path.
419    #[tokio::test]
420    async fn active_dialog_routes_pinned_notes_loaded_rows() {
421        use crate::components::events::PinnedRow;
422        use kimun_core::nfs::VaultPath;
423        use tokio::sync::mpsc::unbounded_channel;
424
425        let vault = crate::test_support::temp_vault("active-dialog-pinned").await;
426        let (tx, _rx) = unbounded_channel();
427        let mut active = ActiveDialog::pinned_notes(vault.clone(), &tx);
428
429        let rows = vec![PinnedRow {
430            path: VaultPath::new("a.md"),
431            missing: false,
432        }];
433        let msg = active.handle_data(
434            &OverlayData::PinnedNotesLoaded(Ok(rows.clone())),
435            &vault,
436            &tx,
437        );
438        assert!(matches!(msg, OverlayMsg::Consumed));
439
440        match &active {
441            ActiveDialog::PinnedNotes(d) => assert_eq!(d.rows(), rows.as_slice()),
442            _ => panic!("expected ActiveDialog::PinnedNotes"),
443        }
444    }
445
446    /// A foreign `OverlayData::Error` — a rename or paste task failing after
447    /// its own dialog closed — must not be routed into the pinned-notes
448    /// dialog as if it were the reload it is waiting on: that would clear
449    /// its in-flight guard and reopen the overlapping-write race.
450    #[tokio::test]
451    async fn active_dialog_leaves_pinned_notes_alone_on_a_foreign_error() {
452        use tokio::sync::mpsc::unbounded_channel;
453
454        let vault = crate::test_support::temp_vault("active-dialog-pinned-err").await;
455        let (tx, mut rx) = unbounded_channel();
456        let mut active = ActiveDialog::pinned_notes(vault.clone(), &tx);
457
458        let msg = active.handle_data(
459            &OverlayData::Error("rename failed".to_string()),
460            &vault,
461            &tx,
462        );
463        assert!(matches!(msg, OverlayMsg::Consumed));
464        assert!(
465            rx.try_recv().is_err(),
466            "a foreign error must not be flashed as a pinned-notes failure"
467        );
468        match &active {
469            ActiveDialog::PinnedNotes(d) => assert!(
470                !d.is_loaded(),
471                "a foreign error must not settle the dialog's own load"
472            ),
473            _ => panic!("expected ActiveDialog::PinnedNotes"),
474        }
475    }
476}