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