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