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                        // Core classifies the entry and routes to the right
124                        // rename (note / directory / attachment); see ADR-0017.
125                        let result = vault.rename_entry(&from, &new_path).await;
126                        match result {
127                            Ok(()) => {
128                                tx2.send(AppEvent::EntryRenamed { from, to: new_path }).ok();
129                            }
130                            Err(e) => {
131                                tx2.send(AppEvent::DialogError(e.to_string())).ok();
132                            }
133                        }
134                    });
135                }
136                EventState::Consumed
137            }
138            InputOutcome::Cancel => {
139                tx.send(AppEvent::CloseOverlay).ok();
140                EventState::Consumed
141            }
142            InputOutcome::Changed => {
143                self.spawn_validation(tx);
144                EventState::Consumed
145            }
146            InputOutcome::Consumed => EventState::Consumed,
147            InputOutcome::NotConsumed => EventState::NotConsumed,
148        }
149    }
150}
151
152// ---------------------------------------------------------------------------
153// Component trait
154// ---------------------------------------------------------------------------
155
156impl Component for RenameDialog {
157    fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, _focused: bool) {
158        // Fixed size: 50 wide; height depends on whether there is an error row.
159        // Border(2) + spacer + path + separator + label + input(3) + validation
160        //           + spacer + hint [+ error] = 11 or 12.
161        let height = if self.error.is_some() { 13 } else { 12 };
162        let popup_area = super::fixed_centered_rect(50, height, rect);
163
164        let inner = modal_chrome(
165            f,
166            popup_area,
167            theme,
168            ModalSpec {
169                title: Some(" Rename "),
170                border: Some(Style::default().fg(theme.fg.to_ratatui())),
171                ..Default::default()
172            },
173        );
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 gray = theme.gray.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], gray, bg);
211
212        // Row 3: "NEW NAME" label.
213        f.render_widget(
214            Paragraph::new("  NEW NAME").style(Style::default().fg(gray).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(gray))
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(gray)),
242            ValidationState::Available => {
243                (" \u{2713} ", Style::default().fg(theme.green.to_ratatui()))
244            }
245            ValidationState::Taken => (" \u{2717} ", Style::default().fg(theme.red.to_ratatui())),
246        };
247        let indicator_rows = Layout::default()
248            .direction(Direction::Vertical)
249            .constraints([
250                Constraint::Length(1),
251                Constraint::Length(1),
252                Constraint::Length(1),
253            ])
254            .split(input_chunks[1]);
255        f.render_widget(
256            Paragraph::new(indicator_text).style(indicator_style.bg(bg)),
257            indicator_rows[1],
258        );
259
260        // Row 5: validation status text.
261        let (status_text, status_style) = match self.validation_state {
262            ValidationState::Idle => ("", Style::default()),
263            ValidationState::Pending => ("  Checking...", Style::default().fg(gray).bg(bg)),
264            ValidationState::Available => (
265                "  Available",
266                Style::default().fg(theme.green.to_ratatui()).bg(bg),
267            ),
268            ValidationState::Taken => (
269                "  Already exists",
270                Style::default().fg(theme.red.to_ratatui()).bg(bg),
271            ),
272        };
273        f.render_widget(Paragraph::new(status_text).style(status_style), rows[5]);
274
275        // Row 7: hint.  Dim the Enter part unless rename is available.
276        super::render_confirm_hint(
277            f,
278            rows[7],
279            "  [Enter] Rename",
280            self.validation_state == ValidationState::Available,
281            fg,
282            gray,
283            bg,
284        );
285
286        // Row 8 (optional): error message.
287        if let Some(msg) = &self.error {
288            super::render_error_row(f, rows[8], msg, theme);
289        }
290    }
291}
292
293// ---------------------------------------------------------------------------
294// Tests
295// ---------------------------------------------------------------------------
296
297#[cfg(test)]
298mod tests {
299    use super::*;
300    use kimun_core::VaultConfig;
301    use tokio::sync::mpsc;
302
303    /// Compile-time smoke test: verify all `ValidationState` variants are
304    /// accessible and exhaustively matched without a real vault.
305    #[test]
306    fn validation_state_variants_compile() {
307        let states = [
308            ValidationState::Idle,
309            ValidationState::Pending,
310            ValidationState::Available,
311            ValidationState::Taken,
312        ];
313        for state in states {
314            let _label = match state {
315                ValidationState::Idle => "idle",
316                ValidationState::Pending => "pending",
317                ValidationState::Available => "available",
318                ValidationState::Taken => "taken",
319            };
320        }
321    }
322
323    /// Verifies that the `input` field is pre-filled with the filename
324    /// component of the supplied path.
325    ///
326    /// This test does not exercise the vault at all — `new()` never calls
327    /// any async vault method — so it runs without any file-system setup.
328    ///
329    /// NOTE: It is gated `#[ignore]` because constructing `NoteVault` requires
330    /// a real SQLite database on disk.  Run it explicitly with:
331    ///
332    /// ```text
333    /// cargo test -- --ignored rename_dialog::tests::new_prefills_input
334    /// ```
335    #[tokio::test]
336    #[ignore = "requires a real vault directory with kimun.sqlite"]
337    async fn new_prefills_input() {
338        use std::path::PathBuf;
339
340        let tmp = std::env::temp_dir().join("kimun_rename_test_vault");
341        std::fs::create_dir_all(&tmp).unwrap();
342
343        let vault = Arc::new(
344            NoteVault::new(VaultConfig::new(PathBuf::from(&tmp)))
345                .await
346                .expect("vault creation failed"),
347        );
348
349        let (_tx, _rx) = mpsc::unbounded_channel::<AppEvent>();
350        let path = VaultPath::new("notes/projects/kimun.md");
351        let (_, expected_filename) = path.get_parent_path();
352
353        let dialog = RenameDialog::new(path, vault);
354        assert_eq!(dialog.input.value(), expected_filename);
355    }
356
357    /// Verifies that pressing `Esc` sends `AppEvent::CloseOverlay` and returns
358    /// `EventState::Consumed`, without touching the vault.
359    #[test]
360    fn esc_sends_close_dialog() {
361        use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
362
363        let rt = tokio::runtime::Runtime::new().unwrap();
364        rt.block_on(async {
365            let tmp = std::env::temp_dir().join("kimun_rename_esc_test");
366            std::fs::create_dir_all(&tmp).unwrap();
367
368            let vault_result = NoteVault::new(VaultConfig::new(tmp)).await;
369            let Ok(vault) = vault_result else {
370                // No vault available in CI — skip gracefully.
371                return;
372            };
373
374            let vault = Arc::new(vault);
375            let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
376            let mut dialog = RenameDialog::new(VaultPath::new("notes/test.md"), vault);
377
378            let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
379            let state = dialog.handle_key(key, &tx);
380
381            assert_eq!(state, EventState::Consumed);
382            let event = rx.try_recv().expect("expected AppEvent::CloseOverlay");
383            assert!(matches!(event, AppEvent::CloseOverlay));
384        });
385    }
386}