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