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};
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    /// [`AppEvent::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::RenameValidation { available: !exists })
95                .ok();
96        });
97
98        self.validation_task = Some(handle);
99        self.validation_state = ValidationState::Pending;
100    }
101
102    // -----------------------------------------------------------------------
103    // Input handling
104    // -----------------------------------------------------------------------
105
106    /// Handle a raw [`KeyEvent`].  Returns [`EventState::Consumed`] for keys
107    /// this dialog acts on; callers should forward only key events.
108    pub fn handle_key(&mut self, key: KeyEvent, tx: &AppTx) -> EventState {
109        match self.input.handle_key(&key) {
110            InputOutcome::Submit => {
111                if self.validation_state == ValidationState::Available {
112                    let from = self.path.clone();
113                    let parent = from.get_parent_path().0;
114                    let new_name = self.input.value();
115                    let new_path = if from.is_note() {
116                        parent.append(&VaultPath::note_path_from(new_name))
117                    } else {
118                        parent.append(&VaultPath::new(new_name))
119                    };
120                    let vault = Arc::clone(&self.vault);
121                    let tx2 = tx.clone();
122                    tokio::spawn(async move {
123                        let result = if from.is_note() {
124                            vault.rename_note(&from, &new_path).await
125                        } else {
126                            vault.rename_directory(&from, &new_path).await
127                        };
128                        match result {
129                            Ok(()) => {
130                                tx2.send(AppEvent::EntryRenamed { from, to: new_path }).ok();
131                            }
132                            Err(e) => {
133                                tx2.send(AppEvent::DialogError(e.to_string())).ok();
134                            }
135                        }
136                    });
137                }
138                EventState::Consumed
139            }
140            InputOutcome::Cancel => {
141                tx.send(AppEvent::CloseOverlay).ok();
142                EventState::Consumed
143            }
144            InputOutcome::Changed => {
145                self.spawn_validation(tx);
146                EventState::Consumed
147            }
148            InputOutcome::Consumed => EventState::Consumed,
149            InputOutcome::NotConsumed => EventState::NotConsumed,
150        }
151    }
152}
153
154// ---------------------------------------------------------------------------
155// Component trait
156// ---------------------------------------------------------------------------
157
158impl Component for RenameDialog {
159    fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, _focused: bool) {
160        // Fixed size: 50 wide; height depends on whether there is an error row.
161        // Border(2) + spacer + path + separator + label + input(3) + validation
162        //           + spacer + hint [+ error] = 11 or 12.
163        let height = if self.error.is_some() { 13 } else { 12 };
164        let popup_area = super::fixed_centered_rect(50, height, rect);
165
166        let inner = modal_chrome(
167            f,
168            popup_area,
169            theme,
170            ModalSpec {
171                title: Some(" Rename "),
172                border: Some(Style::default().fg(theme.fg.to_ratatui())),
173                ..Default::default()
174            },
175        );
176
177        // ── Vertical layout inside the block ─────────────────────────────────
178        //
179        // Row 0: spacer
180        // Row 1: current path
181        // Row 2: separator
182        // Row 3: "NEW NAME" label
183        // Row 4: input field (height 3, bordered)
184        // Row 5: validation status
185        // Row 6: spacer
186        // Row 7: hint line
187        // Row 8 (optional): error line
188
189        let rows = Layout::default()
190            .direction(Direction::Vertical)
191            .constraints([
192                Constraint::Length(1), // 0: spacer
193                Constraint::Length(1), // 1: path
194                Constraint::Length(1), // 2: separator
195                Constraint::Length(1), // 3: "NEW NAME" label
196                Constraint::Length(3), // 4: input field (bordered)
197                Constraint::Length(1), // 5: validation status
198                Constraint::Length(1), // 6: spacer
199                Constraint::Length(1), // 7: hint
200                Constraint::Min(0),    // 8: remainder / error
201            ])
202            .split(inner);
203
204        let bg = theme.bg_panel.to_ratatui();
205        let fg = theme.fg.to_ratatui();
206        let gray = theme.gray.to_ratatui();
207
208        // Row 1: path.
209        super::render_path_row(f, rows[1], &self.path_display, fg, bg);
210
211        // Row 2: separator.
212        super::render_separator(f, rows[2], gray, bg);
213
214        // Row 3: "NEW NAME" label.
215        f.render_widget(
216            Paragraph::new("  NEW NAME").style(Style::default().fg(gray).bg(bg)),
217            rows[3],
218        );
219
220        // Row 4: input field with cursor and validation indicator.
221        //
222        // Split horizontally: [input_area | indicator (3 cols)].
223        let input_chunks = Layout::default()
224            .direction(Direction::Horizontal)
225            .constraints([
226                Constraint::Min(1),    // input field
227                Constraint::Length(3), // validation indicator
228            ])
229            .split(rows[4]);
230
231        let input_block = Block::default()
232            .borders(Borders::ALL)
233            .border_style(Style::default().fg(gray))
234            .style(Style::default().bg(bg));
235        let input_inner = input_block.inner(input_chunks[0]);
236        f.render_widget(input_block, input_chunks[0]);
237        self.input
238            .render(f, input_inner, Style::default().fg(fg).bg(bg), 0, true);
239
240        // Validation indicator glyph, centred vertically in the 3-row area.
241        let (indicator_text, indicator_style) = match self.validation_state {
242            ValidationState::Idle => ("   ", Style::default()),
243            ValidationState::Pending => (" \u{231b} ", Style::default().fg(gray)),
244            ValidationState::Available => {
245                (" \u{2713} ", Style::default().fg(theme.green.to_ratatui()))
246            }
247            ValidationState::Taken => (" \u{2717} ", Style::default().fg(theme.red.to_ratatui())),
248        };
249        let indicator_rows = Layout::default()
250            .direction(Direction::Vertical)
251            .constraints([
252                Constraint::Length(1),
253                Constraint::Length(1),
254                Constraint::Length(1),
255            ])
256            .split(input_chunks[1]);
257        f.render_widget(
258            Paragraph::new(indicator_text).style(indicator_style.bg(bg)),
259            indicator_rows[1],
260        );
261
262        // Row 5: validation status text.
263        let (status_text, status_style) = match self.validation_state {
264            ValidationState::Idle => ("", Style::default()),
265            ValidationState::Pending => ("  Checking...", Style::default().fg(gray).bg(bg)),
266            ValidationState::Available => (
267                "  Available",
268                Style::default().fg(theme.green.to_ratatui()).bg(bg),
269            ),
270            ValidationState::Taken => (
271                "  Already exists",
272                Style::default().fg(theme.red.to_ratatui()).bg(bg),
273            ),
274        };
275        f.render_widget(Paragraph::new(status_text).style(status_style), rows[5]);
276
277        // Row 7: hint.  Dim the Enter part unless rename is available.
278        super::render_confirm_hint(
279            f,
280            rows[7],
281            "  [Enter] Rename",
282            self.validation_state == ValidationState::Available,
283            fg,
284            gray,
285            bg,
286        );
287
288        // Row 8 (optional): error message.
289        if let Some(msg) = &self.error {
290            super::render_error_row(f, rows[8], msg, theme);
291        }
292    }
293}
294
295// ---------------------------------------------------------------------------
296// Tests
297// ---------------------------------------------------------------------------
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302    use kimun_core::VaultConfig;
303    use tokio::sync::mpsc;
304
305    /// Compile-time smoke test: verify all `ValidationState` variants are
306    /// accessible and exhaustively matched without a real vault.
307    #[test]
308    fn validation_state_variants_compile() {
309        let states = [
310            ValidationState::Idle,
311            ValidationState::Pending,
312            ValidationState::Available,
313            ValidationState::Taken,
314        ];
315        for state in states {
316            let _label = match state {
317                ValidationState::Idle => "idle",
318                ValidationState::Pending => "pending",
319                ValidationState::Available => "available",
320                ValidationState::Taken => "taken",
321            };
322        }
323    }
324
325    /// Verifies that the `input` field is pre-filled with the filename
326    /// component of the supplied path.
327    ///
328    /// This test does not exercise the vault at all — `new()` never calls
329    /// any async vault method — so it runs without any file-system setup.
330    ///
331    /// NOTE: It is gated `#[ignore]` because constructing `NoteVault` requires
332    /// a real SQLite database on disk.  Run it explicitly with:
333    ///
334    /// ```text
335    /// cargo test -- --ignored rename_dialog::tests::new_prefills_input
336    /// ```
337    #[tokio::test]
338    #[ignore = "requires a real vault directory with kimun.sqlite"]
339    async fn new_prefills_input() {
340        use std::path::PathBuf;
341
342        let tmp = std::env::temp_dir().join("kimun_rename_test_vault");
343        std::fs::create_dir_all(&tmp).unwrap();
344
345        let vault = Arc::new(
346            NoteVault::new(VaultConfig::new(PathBuf::from(&tmp)))
347                .await
348                .expect("vault creation failed"),
349        );
350
351        let (_tx, _rx) = mpsc::unbounded_channel::<AppEvent>();
352        let path = VaultPath::new("notes/projects/kimun.md");
353        let (_, expected_filename) = path.get_parent_path();
354
355        let dialog = RenameDialog::new(path, vault);
356        assert_eq!(dialog.input.value(), expected_filename);
357    }
358
359    /// Verifies that pressing `Esc` sends `AppEvent::CloseOverlay` and returns
360    /// `EventState::Consumed`, without touching the vault.
361    #[test]
362    fn esc_sends_close_dialog() {
363        use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
364
365        let rt = tokio::runtime::Runtime::new().unwrap();
366        rt.block_on(async {
367            let tmp = std::env::temp_dir().join("kimun_rename_esc_test");
368            std::fs::create_dir_all(&tmp).unwrap();
369
370            let vault_result = NoteVault::new(VaultConfig::new(tmp)).await;
371            let Ok(vault) = vault_result else {
372                // No vault available in CI — skip gracefully.
373                return;
374            };
375
376            let vault = Arc::new(vault);
377            let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
378            let mut dialog = RenameDialog::new(VaultPath::new("notes/test.md"), vault);
379
380            let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
381            let state = dialog.handle_key(key, &tx);
382
383            assert_eq!(state, EventState::Consumed);
384            let event = rx.try_recv().expect("expected AppEvent::CloseOverlay");
385            assert!(matches!(event, AppEvent::CloseOverlay));
386        });
387    }
388}