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