kimun_notes/components/dialogs/
move_dialog.rs1use 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
24pub struct MoveDialog {
36 pub path: VaultPath,
38 pub vault: Arc<NoteVault>,
40 pub path_display: String,
42 pub search_query: SingleLineInput,
44 pub all_dirs: Vec<VaultPath>,
46 pub load_task: Option<JoinHandle<()>>,
48 pub filter_task: Option<JoinHandle<()>>,
50 pub filtered: Option<Vec<VaultPath>>,
52 pub list_state: ListState,
54 pub dest_validation: ValidationState,
56 pub validation_task: Option<JoinHandle<()>>,
58 pub error: Option<String>,
60 list_rect: Rect,
63 hints: HintRow,
64}
65
66impl MoveDialog {
67 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 pub fn results(&self) -> &[VaultPath] {
97 self.filtered.as_deref().unwrap_or(&self.all_dirs)
98 }
99
100 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 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 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 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 pub fn handle_key(&mut self, key: KeyEvent, tx: &AppTx) -> EventState {
261 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 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 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
342impl 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 let rows = Layout::default()
378 .direction(Direction::Vertical)
379 .constraints([
380 Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(3), Constraint::Min(3), Constraint::Length(1), Constraint::Length(1), Constraint::Length(if self.error.is_some() { 1 } else { 0 }), ])
390 .split(inner);
391
392 f.render_widget(
394 Paragraph::new(" MOVING").style(Style::default().fg(gray).bg(bg)),
395 rows[0],
396 );
397
398 super::render_path_row(f, rows[1], &self.path_display, fg, bg);
400
401 f.render_widget(
405 Paragraph::new(" DESTINATION").style(Style::default().fg(gray).bg(bg)),
406 rows[3],
407 );
408
409 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 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 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 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 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 if let Some(msg) = &self.error {
494 super::render_error_row(f, rows[8], msg, theme);
495 }
496 }
497}
498
499#[cfg(test)]
504mod tests {
505 use super::*;
506 use kimun_core::VaultConfig;
507 use tokio::sync::mpsc;
508
509 #[test]
512 fn struct_fields_accessible() {
513 fn _check_error_field(d: &MoveDialog) -> Option<&String> {
515 d.error.as_ref()
516 }
517 fn _check_search_query(d: &MoveDialog) -> &str {
519 d.search_query.value()
520 }
521 fn _check_results(d: &MoveDialog) -> &[VaultPath] {
523 d.results()
524 }
525 fn _check_list_state(d: &mut MoveDialog) -> &mut ListState {
527 &mut d.list_state
528 }
529 }
530
531 #[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 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 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 #[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 assert!(dialog.filtered.is_none());
603 }
604}