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