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};
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
22pub struct MoveDialog {
34 pub path: VaultPath,
36 pub vault: Arc<NoteVault>,
38 pub path_display: String,
40 pub search_query: SingleLineInput,
42 pub all_dirs: Vec<VaultPath>,
44 pub load_task: Option<JoinHandle<()>>,
46 pub filter_task: Option<JoinHandle<()>>,
48 pub filtered: Option<Vec<VaultPath>>,
50 pub list_state: ListState,
52 pub dest_validation: ValidationState,
54 pub validation_task: Option<JoinHandle<()>>,
56 pub error: Option<String>,
58}
59
60impl MoveDialog {
61 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 pub fn results(&self) -> &[VaultPath] {
89 self.filtered.as_deref().unwrap_or(&self.all_dirs)
90 }
91
92 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 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 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 pub fn handle_key(&mut self, key: KeyEvent, tx: &AppTx) -> EventState {
217 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 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 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
300impl 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 let rows = Layout::default()
336 .direction(Direction::Vertical)
337 .constraints([
338 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 }), ])
348 .split(inner);
349
350 f.render_widget(
352 Paragraph::new(" MOVING").style(Style::default().fg(gray).bg(bg)),
353 rows[0],
354 );
355
356 super::render_path_row(f, rows[1], &self.path_display, fg, bg);
358
359 f.render_widget(
363 Paragraph::new(" DESTINATION").style(Style::default().fg(gray).bg(bg)),
364 rows[3],
365 );
366
367 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 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 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 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 if let Some(msg) = &self.error {
443 super::render_error_row(f, rows[8], msg, theme);
444 }
445 }
446}
447
448#[cfg(test)]
453mod tests {
454 use super::*;
455 use kimun_core::VaultConfig;
456 use tokio::sync::mpsc;
457
458 #[test]
461 fn struct_fields_accessible() {
462 fn _check_error_field(d: &MoveDialog) -> Option<&String> {
464 d.error.as_ref()
465 }
466 fn _check_search_query(d: &MoveDialog) -> &str {
468 d.search_query.value()
469 }
470 fn _check_results(d: &MoveDialog) -> &[VaultPath] {
472 d.results()
473 }
474 fn _check_list_state(d: &mut MoveDialog) -> &mut ListState {
476 &mut d.list_state
477 }
478 }
479
480 #[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 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 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 #[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 assert!(dialog.filtered.is_none());
549 }
550}