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, MouseEvent};
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, AppTxExt, OverlayData};
14use crate::components::hint_row::HintRow;
15use crate::components::panel::{ModalSpec, modal_chrome};
16use crate::settings::themes::Theme;
17
18pub struct CreateNoteDialog {
19    /// The note to create. Every caller opens this dialog only for a path no
20    /// note occupies — either because the lookup that led here found nothing,
21    /// or (for a title-derived path) because it was run through
22    /// [`NoteVault::free_note_path`] first. The rendered body states that
23    /// outright, so a path that is already taken would make the dialog lie.
24    pub path: VaultPath,
25    pub vault: Arc<NoteVault>,
26    /// Pre-formatted `"  {path}"` for zero-allocation rendering.
27    pub path_display: String,
28    pub error: Option<String>,
29    /// Body content the created note starts with. `None` creates an empty
30    /// note (the plain create flow); `Some` is the Ask "save as note" action
31    /// (`e` in `ThreadPanel`), which pre-fills the question/answer.
32    pub content: Option<String>,
33    hints: HintRow,
34}
35
36impl CreateNoteDialog {
37    pub fn new(path: VaultPath, vault: Arc<NoteVault>, content: Option<String>) -> Self {
38        let path_display = format!("  {}", path);
39        Self {
40            path,
41            vault,
42            path_display,
43            error: None,
44            content,
45            hints: super::confirm_hints("Create"),
46        }
47    }
48
49    /// A click on a hint chip runs its key. Modal: every mouse event is
50    /// consumed.
51    pub fn handle_mouse(&mut self, m: &MouseEvent, tx: &AppTx) -> EventState {
52        if let Some(key) = self.hints.hit(m) {
53            self.handle_key(key, tx);
54        }
55        EventState::Consumed
56    }
57
58    /// Handle a raw [`KeyEvent`]. Returns [`EventState::Consumed`] for all
59    /// keys this dialog acts on; the caller should forward only key events.
60    pub fn handle_key(&mut self, key: KeyEvent, tx: &AppTx) -> EventState {
61        match key.code {
62            KeyCode::Enter => {
63                let path = self.path.clone();
64                let vault = Arc::clone(&self.vault);
65                let content = self.content.clone();
66                let tx_clone = tx.clone();
67                tokio::spawn(async move {
68                    // Pre-filled content (e.g. a saved Ask answer) must never be
69                    // silently dropped into a pre-existing note of the same name,
70                    // so it always lands in a fresh note rather than going through
71                    // load_or_create_note's load-if-exists behavior.
72                    let result = match &content {
73                        Some(text) => vault
74                            .create_note_avoiding_conflicts(&path, text)
75                            .await
76                            .map(|actual_path| (actual_path, true)),
77                        None => vault
78                            .load_or_create_note(&path, content)
79                            .await
80                            .map(|(_, created)| (path.clone(), created)),
81                    };
82                    match result {
83                        Ok((actual_path, created)) => {
84                            tx_clone.announce_and_open(actual_path, created)
85                        }
86                        Err(e) => {
87                            tx_clone
88                                .send(AppEvent::OverlayData(OverlayData::Error(e.to_string())))
89                                .ok();
90                        }
91                    }
92                });
93                EventState::Consumed
94            }
95            KeyCode::Esc => {
96                tx.send(AppEvent::CloseOverlay).ok();
97                EventState::Consumed
98            }
99            _ => EventState::NotConsumed,
100        }
101    }
102}
103
104impl Component for CreateNoteDialog {
105    fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, _focused: bool) {
106        let height = if self.error.is_some() { 10 } else { 9 };
107        let popup_area = super::fixed_centered_rect(52, height, rect);
108
109        let gray = theme.gray.to_ratatui();
110        let fg = theme.fg.to_ratatui();
111        let bg = theme.bg_panel.to_ratatui();
112
113        let inner = modal_chrome(
114            f,
115            popup_area,
116            theme,
117            ModalSpec {
118                title: Some(" Create note? "),
119                border: Some(Style::default().fg(gray)),
120                ..Default::default()
121            },
122        );
123
124        let rows = Layout::default()
125            .direction(Direction::Vertical)
126            .constraints([
127                Constraint::Length(1), // 0: spacer
128                Constraint::Length(1), // 1: path
129                Constraint::Length(1), // 2: separator
130                Constraint::Length(1), // 3: body
131                Constraint::Length(1), // 4: spacer
132                Constraint::Length(1), // 5: hint
133                Constraint::Length(1), // 6: error (optional)
134                Constraint::Min(0),    // 7: remainder
135            ])
136            .split(inner);
137
138        super::render_path_row(f, rows[1], &self.path_display, fg, bg);
139        super::render_separator(f, rows[2], gray, bg);
140        f.render_widget(
141            Paragraph::new("  Note doesn't exist.").style(Style::default().fg(gray).bg(bg)),
142            rows[3],
143        );
144        self.hints
145            .render(f, rows[5], Style::default().fg(gray).bg(bg), theme);
146        if let Some(msg) = &self.error {
147            super::render_error_row(f, rows[6], msg, theme);
148        }
149    }
150}
151
152impl_dialog!(CreateNoteDialog, error);
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use kimun_core::VaultConfig;
158    use tokio::sync::mpsc;
159
160    /// Full smoke test: creates a `CreateNoteDialog` with a temporary vault
161    /// and asserts the initial `error` field is `None`.
162    ///
163    /// This test requires file-system access and a valid SQLite database, so it
164    /// is gated with `#[ignore]`.  Run it explicitly with:
165    ///
166    /// ```text
167    /// cargo test -- --ignored create_note_dialog::tests::new_with_vault_does_not_panic
168    /// ```
169    #[tokio::test]
170    #[ignore = "requires a real vault directory with kimun.sqlite"]
171    async fn new_with_vault_does_not_panic() {
172        let tmp = std::env::temp_dir().join("kimun_test_vault");
173        std::fs::create_dir_all(&tmp).unwrap();
174
175        let vault = Arc::new(
176            NoteVault::new(VaultConfig::new(crate::test_support::sys(tmp)))
177                .await
178                .expect("vault creation failed"),
179        );
180        let (_tx, _rx) = mpsc::unbounded_channel::<AppEvent>();
181        let dialog = CreateNoteDialog::new(VaultPath::root(), vault, None);
182        assert!(dialog.error.is_none());
183    }
184
185    #[test]
186    fn esc_sends_close_dialog() {
187        use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
188
189        let rt = tokio::runtime::Runtime::new().unwrap();
190        rt.block_on(async {
191            let tmp = std::env::temp_dir().join("kimun_create_esc_test");
192            std::fs::create_dir_all(&tmp).unwrap();
193
194            let vault_result =
195                NoteVault::new(VaultConfig::new(crate::test_support::sys(tmp))).await;
196            let Ok(vault) = vault_result else { return };
197            let vault = Arc::new(vault);
198
199            let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
200            let mut dialog = CreateNoteDialog::new(VaultPath::root(), vault, None);
201
202            let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
203            let state = dialog.handle_key(key, &tx);
204
205            assert_eq!(state, EventState::Consumed);
206            let event = rx.try_recv().expect("expected AppEvent::CloseOverlay");
207            assert!(matches!(event, AppEvent::CloseOverlay));
208        });
209    }
210
211    #[test]
212    fn enter_returns_consumed() {
213        use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
214
215        let rt = tokio::runtime::Runtime::new().unwrap();
216        rt.block_on(async {
217            let tmp = std::env::temp_dir().join("kimun_create_enter_test");
218            std::fs::create_dir_all(&tmp).unwrap();
219
220            let vault_result =
221                NoteVault::new(VaultConfig::new(crate::test_support::sys(tmp))).await;
222            let Ok(vault) = vault_result else { return };
223            let vault = Arc::new(vault);
224
225            let (tx, _rx) = mpsc::unbounded_channel::<AppEvent>();
226            // _rx intentionally dropped — we only assert the synchronous return value (Consumed).
227            // The async task opens the note (and emits EntryCreated when fresh), but
228            // vault.load_or_create_note may fail on the empty tempdir, resulting in
229            // DialogError which we don't assert here.
230            let mut dialog = CreateNoteDialog::new(VaultPath::root(), vault, None);
231
232            let key = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
233            let state = dialog.handle_key(key, &tx);
234
235            assert_eq!(state, EventState::Consumed);
236        });
237    }
238}