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