Skip to main content

kimun_notes/components/dialogs/
move_dialog.rs

1use std::sync::Arc;
2
3use kimun_core::NoteVault;
4use kimun_core::nfs::VaultPath;
5use nucleo::Utf32String;
6use nucleo::pattern::{CaseMatching, Normalization, Pattern};
7use ratatui::Frame;
8use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
9use ratatui::layout::{Constraint, Direction, Layout, Rect};
10use ratatui::style::{Modifier, Style};
11use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
12use tokio::task::JoinHandle;
13
14use crate::components::Component;
15use crate::components::dialogs::ValidationState;
16use crate::components::event_state::EventState;
17use crate::components::events::{AppEvent, AppTx};
18use crate::components::panel::{ModalSpec, modal_chrome};
19use crate::components::single_line_input::{InputOutcome, SingleLineInput};
20use crate::settings::themes::Theme;
21
22// ---------------------------------------------------------------------------
23// MoveDialog
24// ---------------------------------------------------------------------------
25
26/// Modal dialog that lets the user move a note or directory to a different
27/// directory inside the vault.
28///
29/// A background task loads all vault directories asynchronously.  As the user
30/// types a filter query, a second background task runs nucleo fuzzy matching
31/// and sends the ranked results back to the UI thread via a `std::sync::mpsc`
32/// channel that is polled at the start of every `render()` call.
33pub struct MoveDialog {
34    /// The vault path being moved.
35    pub path: VaultPath,
36    /// Shared reference to the vault.
37    pub vault: Arc<NoteVault>,
38    /// Pre-computed `"  {path}"` for zero-allocation rendering.
39    pub path_display: String,
40    /// Current text in the search / filter input.
41    pub search_query: SingleLineInput,
42    /// Full list of directories returned by the vault (populated once load completes).
43    pub all_dirs: Vec<VaultPath>,
44    /// Handle to the directory-load background task.
45    pub load_task: Option<JoinHandle<()>>,
46    /// Handle to the filter background task (aborted on each new keystroke).
47    pub filter_task: Option<JoinHandle<()>>,
48    /// Fuzzy-filter results; `None` means "show all dirs" (no clone needed).
49    pub filtered: Option<Vec<VaultPath>>,
50    /// Selection state for the ratatui `List` widget.
51    pub list_state: ListState,
52    /// Result of the most-recent destination existence check.
53    pub dest_validation: ValidationState,
54    /// Handle to the running validation task so we can abort it on selection change.
55    pub validation_task: Option<JoinHandle<()>>,
56    /// Optional error message surfaced from a failed move attempt.
57    pub error: Option<String>,
58}
59
60impl MoveDialog {
61    /// Create a new `MoveDialog` for `path`.
62    ///
63    /// Directory loading starts immediately in a background task.
64    pub fn new(path: VaultPath, vault: Arc<NoteVault>, tx: &AppTx) -> Self {
65        let path_display = format!("  {}", path);
66        let mut dialog = Self {
67            path,
68            vault,
69            path_display,
70            search_query: SingleLineInput::new(),
71            all_dirs: vec![],
72            load_task: None,
73            filter_task: None,
74            filtered: None,
75            list_state: ListState::default(),
76            dest_validation: ValidationState::Idle,
77            validation_task: None,
78            error: None,
79        };
80        dialog.schedule_load(tx);
81        dialog
82    }
83
84    /// Returns the currently displayed list of directories.
85    ///
86    /// When no filter is active (`filtered` is `None`) this borrows `all_dirs`
87    /// directly — no clone required.
88    pub fn results(&self) -> &[VaultPath] {
89        self.filtered.as_deref().unwrap_or(&self.all_dirs)
90    }
91
92    // -----------------------------------------------------------------------
93    // Load helpers
94    // -----------------------------------------------------------------------
95
96    /// Spawn a background task that retrieves all vault directories and sends
97    /// the result as [`AppEvent::MoveDirectoriesLoaded`].
98    fn schedule_load(&mut self, tx: &AppTx) {
99        let vault = Arc::clone(&self.vault);
100        let tx_clone = tx.clone();
101        let handle = tokio::spawn(async move {
102            let result = tokio::task::spawn_blocking(move || {
103                vault.get_directories(&VaultPath::root(), true)
104            })
105            .await;
106            if let Ok(Ok(dirs)) = result {
107                let mut paths: Vec<VaultPath> = std::iter::once(VaultPath::root())
108                    .chain(dirs.into_iter().map(|d| d.path))
109                    .collect();
110                paths.sort();
111                tx_clone.send(AppEvent::MoveDirectoriesLoaded(paths)).ok();
112            }
113        });
114        self.load_task = Some(handle);
115    }
116
117    // -----------------------------------------------------------------------
118    // Filter helpers
119    // -----------------------------------------------------------------------
120
121    /// Abort any in-flight filter task and schedule a new one for the current
122    /// value of `self.search_query`.  If the query is empty the full
123    /// `all_dirs` list is restored synchronously.  Otherwise the result is
124    /// sent as [`AppEvent::MoveFilterResults`].
125    fn schedule_filter(&mut self, tx: &AppTx) {
126        if let Some(handle) = self.filter_task.take() {
127            handle.abort();
128        }
129
130        if self.search_query.is_empty() {
131            self.filtered = None;
132            if self.list_state.selected().is_none() && !self.results().is_empty() {
133                self.list_state.select(Some(0));
134            }
135            return;
136        }
137
138        let query = self.search_query.value().to_string();
139        let items: Vec<String> = self.all_dirs.iter().map(|p| p.to_string()).collect();
140        let tx_clone = tx.clone();
141
142        let handle = tokio::spawn(async move {
143            let matched_strs = tokio::task::spawn_blocking(move || {
144                let mut matcher = nucleo::Matcher::new(nucleo::Config::DEFAULT);
145                let pattern = Pattern::parse(&query, CaseMatching::Ignore, Normalization::Smart);
146                let mut matched: Vec<(u32, String)> = items
147                    .into_iter()
148                    .filter_map(|item| {
149                        let haystack = Utf32String::from(item.as_str());
150                        pattern
151                            .score(haystack.slice(..), &mut matcher)
152                            .map(|score| (score, item))
153                    })
154                    .collect();
155                matched.sort_by_key(|(score, _)| std::cmp::Reverse(*score));
156                matched.into_iter().map(|(_, s)| s).collect::<Vec<_>>()
157            })
158            .await
159            .unwrap_or_default();
160
161            let paths = matched_strs.iter().map(VaultPath::new).collect();
162            tx_clone.send(AppEvent::MoveFilterResults(paths)).ok();
163        });
164
165        self.filter_task = Some(handle);
166    }
167
168    // -----------------------------------------------------------------------
169    // Destination validation helpers
170    // -----------------------------------------------------------------------
171
172    /// Abort any in-flight validation task and start a new one for the
173    /// currently selected directory.  The result is sent as
174    /// [`AppEvent::MoveDestValidation`].  Resets to `Idle` when nothing is selected.
175    pub fn spawn_validation(&mut self, tx: &AppTx) {
176        if let Some(handle) = self.validation_task.take() {
177            handle.abort();
178        }
179
180        let Some(idx) = self.list_state.selected() else {
181            self.dest_validation = ValidationState::Idle;
182            return;
183        };
184        let Some(dest_dir) = self.results().get(idx).cloned() else {
185            self.dest_validation = ValidationState::Idle;
186            return;
187        };
188
189        let from = self.path.clone();
190        let vault = Arc::clone(&self.vault);
191        let tx_clone = tx.clone();
192
193        let handle = tokio::spawn(async move {
194            let filename = from.get_parent_path().1;
195            let candidate = if from.is_note() {
196                dest_dir.append(&VaultPath::note_path_from(&filename))
197            } else {
198                dest_dir.append(&VaultPath::new(&filename))
199            };
200            let exists = vault.exists(&candidate).await;
201            tx_clone
202                .send(AppEvent::MoveDestValidation { available: !exists })
203                .ok();
204        });
205
206        self.validation_task = Some(handle);
207        self.dest_validation = ValidationState::Pending;
208    }
209
210    // -----------------------------------------------------------------------
211    // Input handling
212    // -----------------------------------------------------------------------
213
214    /// Handle a raw [`KeyEvent`].  Returns [`EventState::Consumed`] for keys
215    /// this dialog acts on; callers should forward only key events.
216    pub fn handle_key(&mut self, key: KeyEvent, tx: &AppTx) -> EventState {
217        // List navigation — handle directly before forwarding to the text input.
218        match key.code {
219            KeyCode::Up => {
220                if let Some(idx) = self.list_state.selected() {
221                    self.list_state.select(Some(idx.saturating_sub(1)));
222                    self.spawn_validation(tx);
223                }
224                return EventState::Consumed;
225            }
226            KeyCode::Down => {
227                if !self.results().is_empty() {
228                    let next = self
229                        .list_state
230                        .selected()
231                        .map_or(0, |i| (i + 1).min(self.results().len() - 1));
232                    self.list_state.select(Some(next));
233                    self.spawn_validation(tx);
234                }
235                return EventState::Consumed;
236            }
237            _ => {}
238        }
239        // Drop Ctrl/Alt-modified chars so combos (e.g. Ctrl+K) don't leak as text.
240        if let KeyCode::Char(_) = key.code {
241            let non_shift = key.modifiers - KeyModifiers::SHIFT;
242            if !non_shift.is_empty() {
243                return EventState::Consumed;
244            }
245        }
246        match self.search_query.handle_key(&key) {
247            InputOutcome::Submit => {
248                if self.dest_validation == ValidationState::Taken {
249                    return EventState::Consumed;
250                }
251                if let Some(selected_idx) = self.list_state.selected()
252                    && selected_idx < self.results().len()
253                {
254                    let from = self.path.clone();
255                    let dest_dir = self.results()[selected_idx].clone();
256                    let filename = from.get_parent_path().1;
257                    let new_path = if from.is_note() {
258                        dest_dir.append(&VaultPath::note_path_from(&filename))
259                    } else {
260                        dest_dir.append(&VaultPath::new(&filename))
261                    };
262                    let vault = Arc::clone(&self.vault);
263                    let tx2 = tx.clone();
264                    tokio::spawn(async move {
265                        // A move is a cross-directory rename; core classifies the
266                        // entry and routes to the right rename (note / directory
267                        // / attachment); see ADR-0017.
268                        let result = vault.rename_entry(&from, &new_path).await;
269                        match result {
270                            Ok(()) => {
271                                tx2.send(AppEvent::EntryMoved { from, to: new_path }).ok();
272                            }
273                            Err(e) => {
274                                tx2.send(AppEvent::DialogError(e.to_string())).ok();
275                            }
276                        }
277                    });
278                }
279                EventState::Consumed
280            }
281            InputOutcome::Cancel => {
282                tx.send(AppEvent::CloseOverlay).ok();
283                EventState::Consumed
284            }
285            InputOutcome::Changed => {
286                self.schedule_filter(tx);
287                self.dest_validation = ValidationState::Idle;
288                EventState::Consumed
289            }
290            InputOutcome::Consumed => EventState::Consumed,
291            InputOutcome::NotConsumed => EventState::NotConsumed,
292        }
293    }
294}
295
296// ---------------------------------------------------------------------------
297// Component trait
298// ---------------------------------------------------------------------------
299
300impl Component for MoveDialog {
301    fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, _focused: bool) {
302        let popup_area = crate::components::centered_rect(50, 60, rect);
303
304        let inner = modal_chrome(
305            f,
306            popup_area,
307            theme,
308            ModalSpec {
309                title: Some(" Move "),
310                border: Some(Style::default().fg(theme.fg.to_ratatui())),
311                ..Default::default()
312            },
313        );
314
315        let bg = theme.bg_panel.to_ratatui();
316        let fg = theme.fg.to_ratatui();
317        let gray = theme.gray.to_ratatui();
318
319        // ── Vertical layout inside the block ─────────────────────────────────
320        //
321        // Row 0: "MOVING" label (muted)
322        // Row 1: source path value
323        // Row 2: spacer
324        // Row 3: "DESTINATION" label (muted)
325        // Row 4: search input field (height 3, bordered)
326        // Row 5: directory list (fills available space)
327        // Row 6: validation status
328        // Row 7: hint line
329        // Row 8 (optional): error line
330
331        let rows = Layout::default()
332            .direction(Direction::Vertical)
333            .constraints([
334                Constraint::Length(1), // 0: "MOVING" label
335                Constraint::Length(1), // 1: source path
336                Constraint::Length(1), // 2: spacer
337                Constraint::Length(1), // 3: "DESTINATION" label
338                Constraint::Length(3), // 4: search input (bordered box)
339                Constraint::Min(3),    // 5: directory list
340                Constraint::Length(1), // 6: validation status
341                Constraint::Length(1), // 7: hint line
342                Constraint::Length(if self.error.is_some() { 1 } else { 0 }), // 8: error
343            ])
344            .split(inner);
345
346        // Row 0: "MOVING" label.
347        f.render_widget(
348            Paragraph::new("  MOVING").style(Style::default().fg(gray).bg(bg)),
349            rows[0],
350        );
351
352        // Row 1: source path.
353        super::render_path_row(f, rows[1], &self.path_display, fg, bg);
354
355        // Row 2: blank spacer — nothing to render.
356
357        // Row 3: "DESTINATION" label.
358        f.render_widget(
359            Paragraph::new("  DESTINATION").style(Style::default().fg(gray).bg(bg)),
360            rows[3],
361        );
362
363        // Row 4: search input with cursor indicator.
364        let input_block = Block::default()
365            .borders(Borders::ALL)
366            .border_style(Style::default().fg(gray))
367            .style(Style::default().bg(bg));
368        let input_inner = input_block.inner(rows[4]);
369        f.render_widget(input_block, rows[4]);
370        self.search_query
371            .render(f, input_inner, Style::default().fg(fg).bg(bg), 0, true);
372
373        // Row 5: directory list (or loading placeholder).
374        let list_items: Vec<ListItem> = if self.results().is_empty() {
375            if self.load_task.is_some() {
376                vec![ListItem::new("  (loading...)").style(Style::default().fg(gray).bg(bg))]
377            } else {
378                vec![ListItem::new("  (no matches)").style(Style::default().fg(gray).bg(bg))]
379            }
380        } else {
381            self.results()
382                .iter()
383                .map(|p| {
384                    let display = if *p == VaultPath::root() {
385                        "  / (vault root)".to_string()
386                    } else {
387                        format!("  {}", p)
388                    };
389                    ListItem::new(display).style(Style::default().fg(fg).bg(bg))
390                })
391                .collect()
392        };
393
394        let list_block = Block::default()
395            .borders(Borders::ALL)
396            .border_style(Style::default().fg(gray))
397            .style(Style::default().bg(bg));
398
399        let list = List::new(list_items)
400            .block(list_block)
401            .highlight_style(
402                Style::default()
403                    .bg(theme.selection_bg.to_ratatui())
404                    .fg(theme.selection_fg.to_ratatui())
405                    .add_modifier(Modifier::BOLD),
406            )
407            .highlight_symbol(">> ");
408
409        f.render_stateful_widget(list, rows[5], &mut self.list_state);
410
411        // Row 6: validation status.
412        let (status_text, status_style) = match self.dest_validation {
413            ValidationState::Idle => ("", Style::default().bg(bg)),
414            ValidationState::Pending => ("  Checking...", Style::default().fg(gray).bg(bg)),
415            ValidationState::Available => (
416                "  Available",
417                Style::default().fg(theme.green.to_ratatui()).bg(bg),
418            ),
419            ValidationState::Taken => (
420                "  Already exists",
421                Style::default().fg(theme.red.to_ratatui()).bg(bg),
422            ),
423        };
424        f.render_widget(Paragraph::new(status_text).style(status_style), rows[6]);
425
426        // Row 7: hint line.  Dim Enter when there's no valid selection.
427        super::render_confirm_hint(
428            f,
429            rows[7],
430            "  [Enter] Move here",
431            self.dest_validation == ValidationState::Available,
432            fg,
433            gray,
434            bg,
435        );
436
437        // Row 8 (optional): error message.
438        if let Some(msg) = &self.error {
439            super::render_error_row(f, rows[8], msg, theme);
440        }
441    }
442}
443
444// ---------------------------------------------------------------------------
445// Tests
446// ---------------------------------------------------------------------------
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451    use kimun_core::VaultConfig;
452    use tokio::sync::mpsc;
453
454    /// Compile-time smoke test: verify that the struct fields and key types
455    /// are accessible without needing a real vault.
456    #[test]
457    fn struct_fields_accessible() {
458        // Verify the `error` field exists and is `Option<String>`.
459        fn _check_error_field(d: &MoveDialog) -> Option<&String> {
460            d.error.as_ref()
461        }
462        // Verify the `search_query` field exists and exposes its value as `&str`.
463        fn _check_search_query(d: &MoveDialog) -> &str {
464            d.search_query.value()
465        }
466        // Verify `results()` accessor returns a slice.
467        fn _check_results(d: &MoveDialog) -> &[VaultPath] {
468            d.results()
469        }
470        // Verify `list_state` field is `ListState`.
471        fn _check_list_state(d: &mut MoveDialog) -> &mut ListState {
472            &mut d.list_state
473        }
474    }
475
476    /// Pressing `Esc` must send `AppEvent::CloseOverlay` and return
477    /// `EventState::Consumed`, without requiring a real vault.
478    #[test]
479    fn esc_sends_close_dialog() {
480        use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
481
482        let rt = tokio::runtime::Runtime::new().unwrap();
483        rt.block_on(async {
484            let tmp = std::env::temp_dir().join("kimun_move_esc_test");
485            std::fs::create_dir_all(&tmp).unwrap();
486
487            let vault_result = NoteVault::new(VaultConfig::new(tmp)).await;
488            let Ok(vault) = vault_result else {
489                // No vault available in CI — skip gracefully.
490                return;
491            };
492
493            let vault = Arc::new(vault);
494            let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
495            let mut dialog = MoveDialog::new(VaultPath::new("notes/test.md"), vault, &tx);
496
497            let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
498            let state = dialog.handle_key(key, &tx);
499
500            assert_eq!(state, EventState::Consumed);
501            // Drain the channel — background tasks (e.g. MoveDirectoriesLoaded)
502            // may have sent events before or after the Esc key was processed.
503            let mut found = false;
504            while let Ok(event) = rx.try_recv() {
505                if matches!(event, AppEvent::CloseOverlay) {
506                    found = true;
507                    break;
508                }
509            }
510            assert!(found, "expected AppEvent::CloseOverlay in channel");
511        });
512    }
513
514    /// A new `MoveDialog` must start with an empty `search_query` and no error.
515    ///
516    /// NOTE: gated `#[ignore]` because constructing `NoteVault` requires a
517    /// real SQLite database on disk.  Run explicitly with:
518    ///
519    /// ```text
520    /// cargo test -- --ignored move_dialog::tests::new_initial_state
521    /// ```
522    #[tokio::test]
523    #[ignore = "requires a real vault directory with kimun.sqlite"]
524    async fn new_initial_state() {
525        use std::path::PathBuf;
526
527        let tmp = std::env::temp_dir().join("kimun_move_test_vault");
528        std::fs::create_dir_all(&tmp).unwrap();
529
530        let vault = Arc::new(
531            NoteVault::new(VaultConfig::new(PathBuf::from(&tmp)))
532                .await
533                .expect("vault creation failed"),
534        );
535
536        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel::<AppEvent>();
537        let path = VaultPath::new("notes/projects/kimun.md");
538        let dialog = MoveDialog::new(path, vault, &tx);
539
540        assert!(dialog.search_query.is_empty());
541        assert!(dialog.error.is_none());
542        // Directory load is async; results may or may not be populated yet.
543        // Assert the invariant that holds regardless: filtered starts as None.
544        assert!(dialog.filtered.is_none());
545    }
546}