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