Skip to main content

kimun_notes/components/dialogs/
rename_dialog.rs

1use std::sync::Arc;
2
3use kimun_core::NoteVault;
4use kimun_core::nfs::VaultPath;
5use ratatui::Frame;
6use ratatui::crossterm::event::KeyEvent;
7use ratatui::layout::{Constraint, Direction, Layout, Rect};
8use ratatui::style::{Color, Style};
9use ratatui::widgets::{Block, Borders, Clear, Paragraph};
10use tokio::task::JoinHandle;
11
12use crate::components::Component;
13use crate::components::dialogs::ValidationState;
14use crate::components::event_state::EventState;
15use crate::components::events::{AppEvent, AppTx};
16use crate::components::single_line_input::{InputOutcome, SingleLineInput};
17use crate::settings::themes::Theme;
18
19// ---------------------------------------------------------------------------
20// RenameDialog
21// ---------------------------------------------------------------------------
22
23/// Modal dialog that lets the user rename a note or directory.
24///
25/// The input is pre-filled with the current filename.  As the user types,
26/// an async task checks whether the new name already exists in the vault and
27/// updates `validation_state` accordingly.  Pressing `Enter` while the name
28/// is `Available` triggers the actual rename operation.
29pub struct RenameDialog {
30    /// The vault path being renamed.
31    pub path: VaultPath,
32    /// Shared reference to the vault for existence checks and the rename op.
33    pub vault: Arc<NoteVault>,
34    /// Pre-computed `"  {path}"` for zero-allocation rendering.
35    pub path_display: String,
36    /// Current text in the input field.
37    pub input: SingleLineInput,
38    /// Result of the most-recent validation check.
39    pub validation_state: ValidationState,
40    /// Handle to the running validation task so we can abort it on new input.
41    pub validation_task: Option<JoinHandle<()>>,
42    /// Optional error message surfaced from a failed rename attempt.
43    pub error: Option<String>,
44}
45
46impl RenameDialog {
47    /// Create a new `RenameDialog` for `path`.
48    ///
49    /// The input field is pre-filled with the filename component of `path`.
50    pub fn new(path: VaultPath, vault: Arc<NoteVault>) -> Self {
51        let (_, filename) = path.get_parent_path();
52        let path_display = format!("  {}", path);
53        Self {
54            path,
55            vault,
56            path_display,
57            input: SingleLineInput::with_value(filename),
58            validation_state: ValidationState::Idle,
59            validation_task: None,
60            error: None,
61        }
62    }
63
64    // -----------------------------------------------------------------------
65    // Validation helpers
66    // -----------------------------------------------------------------------
67
68    /// Abort any in-flight validation task and spawn a new one for the
69    /// current value of `self.input`.  The result is sent as
70    /// [`AppEvent::RenameValidation`] so that state updates happen in
71    /// `handle_app_message` rather than in `render`.
72    fn spawn_validation(&mut self, tx: &AppTx) {
73        // Abort the previous task if it is still running.
74        if let Some(handle) = self.validation_task.take() {
75            handle.abort();
76        }
77
78        let vault = Arc::clone(&self.vault);
79        let input = self.input.value().to_string();
80        let path = self.path.clone();
81        let tx_clone = tx.clone();
82
83        let handle = tokio::spawn(async move {
84            let parent = path.get_parent_path().0;
85            let candidate = if path.is_note() {
86                parent.append(&VaultPath::note_path_from(&input))
87            } else {
88                parent.append(&VaultPath::new(&input))
89            };
90            let exists = vault.exists(&candidate).await;
91            // `true` means the name is *available* (does not exist yet).
92            tx_clone
93                .send(AppEvent::RenameValidation { available: !exists })
94                .ok();
95        });
96
97        self.validation_task = Some(handle);
98        self.validation_state = ValidationState::Pending;
99    }
100
101    // -----------------------------------------------------------------------
102    // Input handling
103    // -----------------------------------------------------------------------
104
105    /// Handle a raw [`KeyEvent`].  Returns [`EventState::Consumed`] for keys
106    /// this dialog acts on; callers should forward only key events.
107    pub fn handle_key(&mut self, key: KeyEvent, tx: &AppTx) -> EventState {
108        match self.input.handle_key(&key) {
109            InputOutcome::Submit => {
110                if self.validation_state == ValidationState::Available {
111                    let from = self.path.clone();
112                    let parent = from.get_parent_path().0;
113                    let new_name = self.input.value();
114                    let new_path = if from.is_note() {
115                        parent.append(&VaultPath::note_path_from(new_name))
116                    } else {
117                        parent.append(&VaultPath::new(new_name))
118                    };
119                    let vault = Arc::clone(&self.vault);
120                    let tx2 = tx.clone();
121                    tokio::spawn(async move {
122                        let result = if from.is_note() {
123                            vault.rename_note(&from, &new_path).await
124                        } else {
125                            vault.rename_directory(&from, &new_path).await
126                        };
127                        match result {
128                            Ok(()) => {
129                                tx2.send(AppEvent::EntryRenamed { from, to: new_path }).ok();
130                            }
131                            Err(e) => {
132                                tx2.send(AppEvent::DialogError(e.to_string())).ok();
133                            }
134                        }
135                    });
136                }
137                EventState::Consumed
138            }
139            InputOutcome::Cancel => {
140                tx.send(AppEvent::CloseOverlay).ok();
141                EventState::Consumed
142            }
143            InputOutcome::Changed => {
144                self.spawn_validation(tx);
145                EventState::Consumed
146            }
147            InputOutcome::Consumed => EventState::Consumed,
148            InputOutcome::NotConsumed => EventState::NotConsumed,
149        }
150    }
151}
152
153// ---------------------------------------------------------------------------
154// Component trait
155// ---------------------------------------------------------------------------
156
157impl Component for RenameDialog {
158    fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, _focused: bool) {
159        // Fixed size: 50 wide; height depends on whether there is an error row.
160        // Border(2) + spacer + path + separator + label + input(3) + validation
161        //           + spacer + hint [+ error] = 11 or 12.
162        let height = if self.error.is_some() { 13 } else { 12 };
163        let popup_area = super::fixed_centered_rect(50, height, rect);
164
165        f.render_widget(Clear, popup_area);
166
167        let outer_block = Block::default()
168            .title(" Rename ")
169            .borders(Borders::ALL)
170            .border_style(Style::default().fg(theme.fg.to_ratatui()))
171            .style(theme.panel_style());
172        let inner = outer_block.inner(popup_area);
173        f.render_widget(outer_block, popup_area);
174
175        // ── Vertical layout inside the block ─────────────────────────────────
176        //
177        // Row 0: spacer
178        // Row 1: current path
179        // Row 2: separator
180        // Row 3: "NEW NAME" label
181        // Row 4: input field (height 3, bordered)
182        // Row 5: validation status
183        // Row 6: spacer
184        // Row 7: hint line
185        // Row 8 (optional): error line
186
187        let rows = Layout::default()
188            .direction(Direction::Vertical)
189            .constraints([
190                Constraint::Length(1), // 0: spacer
191                Constraint::Length(1), // 1: path
192                Constraint::Length(1), // 2: separator
193                Constraint::Length(1), // 3: "NEW NAME" label
194                Constraint::Length(3), // 4: input field (bordered)
195                Constraint::Length(1), // 5: validation status
196                Constraint::Length(1), // 6: spacer
197                Constraint::Length(1), // 7: hint
198                Constraint::Min(0),    // 8: remainder / error
199            ])
200            .split(inner);
201
202        let bg = theme.bg_panel.to_ratatui();
203        let fg = theme.fg.to_ratatui();
204        let fg_muted = theme.fg_muted.to_ratatui();
205
206        // Row 1: path.
207        super::render_path_row(f, rows[1], &self.path_display, fg, bg);
208
209        // Row 2: separator.
210        super::render_separator(f, rows[2], fg_muted, bg);
211
212        // Row 3: "NEW NAME" label.
213        f.render_widget(
214            Paragraph::new("  NEW NAME").style(Style::default().fg(fg_muted).bg(bg)),
215            rows[3],
216        );
217
218        // Row 4: input field with cursor and validation indicator.
219        //
220        // Split horizontally: [input_area | indicator (3 cols)].
221        let input_chunks = Layout::default()
222            .direction(Direction::Horizontal)
223            .constraints([
224                Constraint::Min(1),    // input field
225                Constraint::Length(3), // validation indicator
226            ])
227            .split(rows[4]);
228
229        let input_block = Block::default()
230            .borders(Borders::ALL)
231            .border_style(Style::default().fg(fg_muted))
232            .style(Style::default().bg(bg));
233        let input_inner = input_block.inner(input_chunks[0]);
234        f.render_widget(input_block, input_chunks[0]);
235        self.input
236            .render(f, input_inner, Style::default().fg(fg).bg(bg), 0, true);
237
238        // Validation indicator glyph, centred vertically in the 3-row area.
239        let (indicator_text, indicator_style) = match self.validation_state {
240            ValidationState::Idle => ("   ", Style::default()),
241            ValidationState::Pending => (" \u{231b} ", Style::default().fg(fg_muted)),
242            ValidationState::Available => (" \u{2713} ", Style::default().fg(Color::Green)),
243            ValidationState::Taken => (" \u{2717} ", Style::default().fg(Color::Red)),
244        };
245        let indicator_rows = Layout::default()
246            .direction(Direction::Vertical)
247            .constraints([
248                Constraint::Length(1),
249                Constraint::Length(1),
250                Constraint::Length(1),
251            ])
252            .split(input_chunks[1]);
253        f.render_widget(
254            Paragraph::new(indicator_text).style(indicator_style.bg(bg)),
255            indicator_rows[1],
256        );
257
258        // Row 5: validation status text.
259        let (status_text, status_style) = match self.validation_state {
260            ValidationState::Idle => ("", Style::default()),
261            ValidationState::Pending => ("  Checking...", Style::default().fg(fg_muted).bg(bg)),
262            ValidationState::Available => ("  Available", Style::default().fg(Color::Green).bg(bg)),
263            ValidationState::Taken => ("  Already exists", Style::default().fg(Color::Red).bg(bg)),
264        };
265        f.render_widget(Paragraph::new(status_text).style(status_style), rows[5]);
266
267        // Row 7: hint.  Dim the Enter part unless rename is available.
268        super::render_confirm_hint(
269            f,
270            rows[7],
271            "  [Enter] Rename",
272            self.validation_state == ValidationState::Available,
273            fg,
274            fg_muted,
275            bg,
276        );
277
278        // Row 8 (optional): error message.
279        if let Some(msg) = &self.error {
280            super::render_error_row(f, rows[8], msg, bg);
281        }
282    }
283}
284
285// ---------------------------------------------------------------------------
286// Tests
287// ---------------------------------------------------------------------------
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use kimun_core::VaultConfig;
293    use tokio::sync::mpsc;
294
295    /// Compile-time smoke test: verify all `ValidationState` variants are
296    /// accessible and exhaustively matched without a real vault.
297    #[test]
298    fn validation_state_variants_compile() {
299        let states = [
300            ValidationState::Idle,
301            ValidationState::Pending,
302            ValidationState::Available,
303            ValidationState::Taken,
304        ];
305        for state in states {
306            let _label = match state {
307                ValidationState::Idle => "idle",
308                ValidationState::Pending => "pending",
309                ValidationState::Available => "available",
310                ValidationState::Taken => "taken",
311            };
312        }
313    }
314
315    /// Verifies that the `input` field is pre-filled with the filename
316    /// component of the supplied path.
317    ///
318    /// This test does not exercise the vault at all — `new()` never calls
319    /// any async vault method — so it runs without any file-system setup.
320    ///
321    /// NOTE: It is gated `#[ignore]` because constructing `NoteVault` requires
322    /// a real SQLite database on disk.  Run it explicitly with:
323    ///
324    /// ```text
325    /// cargo test -- --ignored rename_dialog::tests::new_prefills_input
326    /// ```
327    #[tokio::test]
328    #[ignore = "requires a real vault directory with kimun.sqlite"]
329    async fn new_prefills_input() {
330        use std::path::PathBuf;
331
332        let tmp = std::env::temp_dir().join("kimun_rename_test_vault");
333        std::fs::create_dir_all(&tmp).unwrap();
334
335        let vault = Arc::new(
336            NoteVault::new(VaultConfig::new(PathBuf::from(&tmp)))
337                .await
338                .expect("vault creation failed"),
339        );
340
341        let (_tx, _rx) = mpsc::unbounded_channel::<AppEvent>();
342        let path = VaultPath::new("notes/projects/kimun.md");
343        let (_, expected_filename) = path.get_parent_path();
344
345        let dialog = RenameDialog::new(path, vault);
346        assert_eq!(dialog.input.value(), expected_filename);
347    }
348
349    /// Verifies that pressing `Esc` sends `AppEvent::CloseOverlay` and returns
350    /// `EventState::Consumed`, without touching the vault.
351    #[test]
352    fn esc_sends_close_dialog() {
353        use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
354
355        let rt = tokio::runtime::Runtime::new().unwrap();
356        rt.block_on(async {
357            let tmp = std::env::temp_dir().join("kimun_rename_esc_test");
358            std::fs::create_dir_all(&tmp).unwrap();
359
360            let vault_result = NoteVault::new(VaultConfig::new(tmp)).await;
361            let Ok(vault) = vault_result else {
362                // No vault available in CI — skip gracefully.
363                return;
364            };
365
366            let vault = Arc::new(vault);
367            let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
368            let mut dialog = RenameDialog::new(VaultPath::new("notes/test.md"), vault);
369
370            let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
371            let state = dialog.handle_key(key, &tx);
372
373            assert_eq!(state, EventState::Consumed);
374            let event = rx.try_recv().expect("expected AppEvent::CloseOverlay");
375            assert!(matches!(event, AppEvent::CloseOverlay));
376        });
377    }
378}