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