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