Skip to main content

kimun_notes/components/dialogs/
create_note_dialog.rs

1use std::sync::Arc;
2
3use kimun_core::NoteVault;
4use kimun_core::nfs::VaultPath;
5use ratatui::Frame;
6use ratatui::crossterm::event::{KeyCode, KeyEvent};
7use ratatui::layout::{Constraint, Direction, Layout, Rect};
8use ratatui::style::Style;
9use ratatui::widgets::Paragraph;
10
11use crate::components::Component;
12use crate::components::event_state::EventState;
13use crate::components::events::{AppEvent, AppTx};
14use crate::components::panel::{ModalSpec, modal_chrome};
15use crate::settings::themes::Theme;
16
17pub struct CreateNoteDialog {
18    pub path: VaultPath,
19    pub vault: Arc<NoteVault>,
20    /// Pre-formatted `"  {path}"` for zero-allocation rendering.
21    pub path_display: String,
22    pub error: Option<String>,
23}
24
25impl CreateNoteDialog {
26    pub fn new(path: VaultPath, vault: Arc<NoteVault>) -> Self {
27        let path_display = format!("  {}", path);
28        Self {
29            path,
30            vault,
31            path_display,
32            error: None,
33        }
34    }
35
36    /// Handle a raw [`KeyEvent`]. Returns [`EventState::Consumed`] for all
37    /// keys this dialog acts on; the caller should forward only key events.
38    pub fn handle_key(&mut self, key: KeyEvent, tx: &AppTx) -> EventState {
39        match key.code {
40            KeyCode::Enter => {
41                let path = self.path.clone();
42                let vault = Arc::clone(&self.vault);
43                let tx_clone = tx.clone();
44                tokio::spawn(async move {
45                    match vault.load_or_create_note(&path, None).await {
46                        Ok(_) => {
47                            tx_clone.send(AppEvent::EntryCreated(path)).ok();
48                        }
49                        Err(e) => {
50                            tx_clone.send(AppEvent::DialogError(e.to_string())).ok();
51                        }
52                    }
53                });
54                EventState::Consumed
55            }
56            KeyCode::Esc => {
57                tx.send(AppEvent::CloseOverlay).ok();
58                EventState::Consumed
59            }
60            _ => EventState::NotConsumed,
61        }
62    }
63}
64
65impl Component for CreateNoteDialog {
66    fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, _focused: bool) {
67        let height = if self.error.is_some() { 10 } else { 9 };
68        let popup_area = super::fixed_centered_rect(52, height, rect);
69
70        let gray = theme.gray.to_ratatui();
71        let fg = theme.fg.to_ratatui();
72        let bg = theme.bg_panel.to_ratatui();
73
74        let inner = modal_chrome(
75            f,
76            popup_area,
77            theme,
78            ModalSpec {
79                title: Some(" Create note? "),
80                border: Some(Style::default().fg(gray)),
81                ..Default::default()
82            },
83        );
84
85        let rows = Layout::default()
86            .direction(Direction::Vertical)
87            .constraints([
88                Constraint::Length(1), // 0: spacer
89                Constraint::Length(1), // 1: path
90                Constraint::Length(1), // 2: separator
91                Constraint::Length(1), // 3: body
92                Constraint::Length(1), // 4: spacer
93                Constraint::Length(1), // 5: hint
94                Constraint::Length(1), // 6: error (optional)
95                Constraint::Min(0),    // 7: remainder
96            ])
97            .split(inner);
98
99        super::render_path_row(f, rows[1], &self.path_display, fg, bg);
100        super::render_separator(f, rows[2], gray, bg);
101        f.render_widget(
102            Paragraph::new("  Note doesn't exist.").style(Style::default().fg(gray).bg(bg)),
103            rows[3],
104        );
105        f.render_widget(
106            Paragraph::new("  [Enter] Create   [Esc] Cancel")
107                .style(Style::default().fg(gray).bg(bg)),
108            rows[5],
109        );
110        if let Some(msg) = &self.error {
111            super::render_error_row(f, rows[6], msg, theme);
112        }
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use kimun_core::VaultConfig;
120    use tokio::sync::mpsc;
121
122    /// Full smoke test: creates a `CreateNoteDialog` with a temporary vault
123    /// and asserts the initial `error` field is `None`.
124    ///
125    /// This test requires file-system access and a valid SQLite database, so it
126    /// is gated with `#[ignore]`.  Run it explicitly with:
127    ///
128    /// ```text
129    /// cargo test -- --ignored create_note_dialog::tests::new_with_vault_does_not_panic
130    /// ```
131    #[tokio::test]
132    #[ignore = "requires a real vault directory with kimun.sqlite"]
133    async fn new_with_vault_does_not_panic() {
134        let tmp = std::env::temp_dir().join("kimun_test_vault");
135        std::fs::create_dir_all(&tmp).unwrap();
136
137        let vault = Arc::new(
138            NoteVault::new(VaultConfig::new(tmp))
139                .await
140                .expect("vault creation failed"),
141        );
142        let (_tx, _rx) = mpsc::unbounded_channel::<AppEvent>();
143        let dialog = CreateNoteDialog::new(VaultPath::root(), vault);
144        assert!(dialog.error.is_none());
145    }
146
147    #[test]
148    fn esc_sends_close_dialog() {
149        use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
150
151        let rt = tokio::runtime::Runtime::new().unwrap();
152        rt.block_on(async {
153            let tmp = std::env::temp_dir().join("kimun_create_esc_test");
154            std::fs::create_dir_all(&tmp).unwrap();
155
156            let vault_result = NoteVault::new(VaultConfig::new(tmp)).await;
157            let Ok(vault) = vault_result else { return };
158            let vault = Arc::new(vault);
159
160            let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
161            let mut dialog = CreateNoteDialog::new(VaultPath::root(), vault);
162
163            let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
164            let state = dialog.handle_key(key, &tx);
165
166            assert_eq!(state, EventState::Consumed);
167            let event = rx.try_recv().expect("expected AppEvent::CloseOverlay");
168            assert!(matches!(event, AppEvent::CloseOverlay));
169        });
170    }
171
172    #[test]
173    fn enter_returns_consumed() {
174        use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
175
176        let rt = tokio::runtime::Runtime::new().unwrap();
177        rt.block_on(async {
178            let tmp = std::env::temp_dir().join("kimun_create_enter_test");
179            std::fs::create_dir_all(&tmp).unwrap();
180
181            let vault_result = NoteVault::new(VaultConfig::new(tmp)).await;
182            let Ok(vault) = vault_result else { return };
183            let vault = Arc::new(vault);
184
185            let (tx, _rx) = mpsc::unbounded_channel::<AppEvent>();
186            // _rx intentionally dropped — we only assert the synchronous return value (Consumed).
187            // The async task sends EntryCreated but vault.load_or_create_note will fail on the empty
188            // tempdir, resulting in DialogError which we don't assert here.
189            let mut dialog = CreateNoteDialog::new(VaultPath::root(), vault);
190
191            let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
192            let state = dialog.handle_key(key, &tx);
193
194            assert_eq!(state, EventState::Consumed);
195        });
196    }
197}