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                        // The vault has no dedicated move API; rename_note /
266                        // rename_directory accept paths in different directories,
267                        // so a cross-directory rename is equivalent to a move.
268                        let result = if from.is_note() {
269                            vault.rename_note(&from, &new_path).await
270                        } else {
271                            vault.rename_directory(&from, &new_path).await
272                        };
273                        match result {
274                            Ok(()) => {
275                                tx2.send(AppEvent::EntryMoved { from, to: new_path }).ok();
276                            }
277                            Err(e) => {
278                                tx2.send(AppEvent::DialogError(e.to_string())).ok();
279                            }
280                        }
281                    });
282                }
283                EventState::Consumed
284            }
285            InputOutcome::Cancel => {
286                tx.send(AppEvent::CloseOverlay).ok();
287                EventState::Consumed
288            }
289            InputOutcome::Changed => {
290                self.schedule_filter(tx);
291                self.dest_validation = ValidationState::Idle;
292                EventState::Consumed
293            }
294            InputOutcome::Consumed => EventState::Consumed,
295            InputOutcome::NotConsumed => EventState::NotConsumed,
296        }
297    }
298}
299
300// ---------------------------------------------------------------------------
301// Component trait
302// ---------------------------------------------------------------------------
303
304impl Component for MoveDialog {
305    fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, _focused: bool) {
306        let popup_area = crate::components::centered_rect(50, 60, rect);
307
308        let inner = modal_chrome(
309            f,
310            popup_area,
311            theme,
312            ModalSpec {
313                title: Some(" Move "),
314                border: Some(Style::default().fg(theme.fg.to_ratatui())),
315                ..Default::default()
316            },
317        );
318
319        let bg = theme.bg_panel.to_ratatui();
320        let fg = theme.fg.to_ratatui();
321        let gray = theme.gray.to_ratatui();
322
323        // ── Vertical layout inside the block ─────────────────────────────────
324        //
325        // Row 0: "MOVING" label (muted)
326        // Row 1: source path value
327        // Row 2: spacer
328        // Row 3: "DESTINATION" label (muted)
329        // Row 4: search input field (height 3, bordered)
330        // Row 5: directory list (fills available space)
331        // Row 6: validation status
332        // Row 7: hint line
333        // Row 8 (optional): error line
334
335        let rows = Layout::default()
336            .direction(Direction::Vertical)
337            .constraints([
338                Constraint::Length(1), // 0: "MOVING" label
339                Constraint::Length(1), // 1: source path
340                Constraint::Length(1), // 2: spacer
341                Constraint::Length(1), // 3: "DESTINATION" label
342                Constraint::Length(3), // 4: search input (bordered box)
343                Constraint::Min(3),    // 5: directory list
344                Constraint::Length(1), // 6: validation status
345                Constraint::Length(1), // 7: hint line
346                Constraint::Length(if self.error.is_some() { 1 } else { 0 }), // 8: error
347            ])
348            .split(inner);
349
350        // Row 0: "MOVING" label.
351        f.render_widget(
352            Paragraph::new("  MOVING").style(Style::default().fg(gray).bg(bg)),
353            rows[0],
354        );
355
356        // Row 1: source path.
357        super::render_path_row(f, rows[1], &self.path_display, fg, bg);
358
359        // Row 2: blank spacer — nothing to render.
360
361        // Row 3: "DESTINATION" label.
362        f.render_widget(
363            Paragraph::new("  DESTINATION").style(Style::default().fg(gray).bg(bg)),
364            rows[3],
365        );
366
367        // Row 4: search input with cursor indicator.
368        let input_block = Block::default()
369            .borders(Borders::ALL)
370            .border_style(Style::default().fg(gray))
371            .style(Style::default().bg(bg));
372        let input_inner = input_block.inner(rows[4]);
373        f.render_widget(input_block, rows[4]);
374        self.search_query
375            .render(f, input_inner, Style::default().fg(fg).bg(bg), 0, true);
376
377        // Row 5: directory list (or loading placeholder).
378        let list_items: Vec<ListItem> = if self.results().is_empty() {
379            if self.load_task.is_some() {
380                vec![ListItem::new("  (loading...)").style(Style::default().fg(gray).bg(bg))]
381            } else {
382                vec![ListItem::new("  (no matches)").style(Style::default().fg(gray).bg(bg))]
383            }
384        } else {
385            self.results()
386                .iter()
387                .map(|p| {
388                    let display = if *p == VaultPath::root() {
389                        "  / (vault root)".to_string()
390                    } else {
391                        format!("  {}", p)
392                    };
393                    ListItem::new(display).style(Style::default().fg(fg).bg(bg))
394                })
395                .collect()
396        };
397
398        let list_block = Block::default()
399            .borders(Borders::ALL)
400            .border_style(Style::default().fg(gray))
401            .style(Style::default().bg(bg));
402
403        let list = List::new(list_items)
404            .block(list_block)
405            .highlight_style(
406                Style::default()
407                    .bg(theme.selection_bg.to_ratatui())
408                    .fg(theme.selection_fg.to_ratatui())
409                    .add_modifier(Modifier::BOLD),
410            )
411            .highlight_symbol(">> ");
412
413        f.render_stateful_widget(list, rows[5], &mut self.list_state);
414
415        // Row 6: validation status.
416        let (status_text, status_style) = match self.dest_validation {
417            ValidationState::Idle => ("", Style::default().bg(bg)),
418            ValidationState::Pending => ("  Checking...", Style::default().fg(gray).bg(bg)),
419            ValidationState::Available => (
420                "  Available",
421                Style::default().fg(theme.green.to_ratatui()).bg(bg),
422            ),
423            ValidationState::Taken => (
424                "  Already exists",
425                Style::default().fg(theme.red.to_ratatui()).bg(bg),
426            ),
427        };
428        f.render_widget(Paragraph::new(status_text).style(status_style), rows[6]);
429
430        // Row 7: hint line.  Dim Enter when there's no valid selection.
431        super::render_confirm_hint(
432            f,
433            rows[7],
434            "  [Enter] Move here",
435            self.dest_validation == ValidationState::Available,
436            fg,
437            gray,
438            bg,
439        );
440
441        // Row 8 (optional): error message.
442        if let Some(msg) = &self.error {
443            super::render_error_row(f, rows[8], msg, theme);
444        }
445    }
446}
447
448// ---------------------------------------------------------------------------
449// Tests
450// ---------------------------------------------------------------------------
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455    use kimun_core::VaultConfig;
456    use tokio::sync::mpsc;
457
458    /// Compile-time smoke test: verify that the struct fields and key types
459    /// are accessible without needing a real vault.
460    #[test]
461    fn struct_fields_accessible() {
462        // Verify the `error` field exists and is `Option<String>`.
463        fn _check_error_field(d: &MoveDialog) -> Option<&String> {
464            d.error.as_ref()
465        }
466        // Verify the `search_query` field exists and exposes its value as `&str`.
467        fn _check_search_query(d: &MoveDialog) -> &str {
468            d.search_query.value()
469        }
470        // Verify `results()` accessor returns a slice.
471        fn _check_results(d: &MoveDialog) -> &[VaultPath] {
472            d.results()
473        }
474        // Verify `list_state` field is `ListState`.
475        fn _check_list_state(d: &mut MoveDialog) -> &mut ListState {
476            &mut d.list_state
477        }
478    }
479
480    /// Pressing `Esc` must send `AppEvent::CloseOverlay` and return
481    /// `EventState::Consumed`, without requiring a real vault.
482    #[test]
483    fn esc_sends_close_dialog() {
484        use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
485
486        let rt = tokio::runtime::Runtime::new().unwrap();
487        rt.block_on(async {
488            let tmp = std::env::temp_dir().join("kimun_move_esc_test");
489            std::fs::create_dir_all(&tmp).unwrap();
490
491            let vault_result = NoteVault::new(VaultConfig::new(tmp)).await;
492            let Ok(vault) = vault_result else {
493                // No vault available in CI — skip gracefully.
494                return;
495            };
496
497            let vault = Arc::new(vault);
498            let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
499            let mut dialog = MoveDialog::new(VaultPath::new("notes/test.md"), vault, &tx);
500
501            let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
502            let state = dialog.handle_key(key, &tx);
503
504            assert_eq!(state, EventState::Consumed);
505            // Drain the channel — background tasks (e.g. MoveDirectoriesLoaded)
506            // may have sent events before or after the Esc key was processed.
507            let mut found = false;
508            while let Ok(event) = rx.try_recv() {
509                if matches!(event, AppEvent::CloseOverlay) {
510                    found = true;
511                    break;
512                }
513            }
514            assert!(found, "expected AppEvent::CloseOverlay in channel");
515        });
516    }
517
518    /// A new `MoveDialog` must start with an empty `search_query` and no error.
519    ///
520    /// NOTE: gated `#[ignore]` because constructing `NoteVault` requires a
521    /// real SQLite database on disk.  Run explicitly with:
522    ///
523    /// ```text
524    /// cargo test -- --ignored move_dialog::tests::new_initial_state
525    /// ```
526    #[tokio::test]
527    #[ignore = "requires a real vault directory with kimun.sqlite"]
528    async fn new_initial_state() {
529        use std::path::PathBuf;
530
531        let tmp = std::env::temp_dir().join("kimun_move_test_vault");
532        std::fs::create_dir_all(&tmp).unwrap();
533
534        let vault = Arc::new(
535            NoteVault::new(VaultConfig::new(PathBuf::from(&tmp)))
536                .await
537                .expect("vault creation failed"),
538        );
539
540        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel::<AppEvent>();
541        let path = VaultPath::new("notes/projects/kimun.md");
542        let dialog = MoveDialog::new(path, vault, &tx);
543
544        assert!(dialog.search_query.is_empty());
545        assert!(dialog.error.is_none());
546        // Directory load is async; results may or may not be populated yet.
547        // Assert the invariant that holds regardless: filtered starts as None.
548        assert!(dialog.filtered.is_none());
549    }
550}