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 = vault.rename_entry(&from, &new_path).await;
269 match result {
270 Ok(()) => {
271 tx2.send(AppEvent::EntryMoved { from, to: new_path }).ok();
272 }
273 Err(e) => {
274 tx2.send(AppEvent::DialogError(e.to_string())).ok();
275 }
276 }
277 });
278 }
279 EventState::Consumed
280 }
281 InputOutcome::Cancel => {
282 tx.send(AppEvent::CloseOverlay).ok();
283 EventState::Consumed
284 }
285 InputOutcome::Changed => {
286 self.schedule_filter(tx);
287 self.dest_validation = ValidationState::Idle;
288 EventState::Consumed
289 }
290 InputOutcome::Consumed => EventState::Consumed,
291 InputOutcome::NotConsumed => EventState::NotConsumed,
292 }
293 }
294}
295
296impl Component for MoveDialog {
301 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, _focused: bool) {
302 let popup_area = crate::components::centered_rect(50, 60, rect);
303
304 let inner = modal_chrome(
305 f,
306 popup_area,
307 theme,
308 ModalSpec {
309 title: Some(" Move "),
310 border: Some(Style::default().fg(theme.fg.to_ratatui())),
311 ..Default::default()
312 },
313 );
314
315 let bg = theme.bg_panel.to_ratatui();
316 let fg = theme.fg.to_ratatui();
317 let gray = theme.gray.to_ratatui();
318
319 let rows = Layout::default()
332 .direction(Direction::Vertical)
333 .constraints([
334 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 }), ])
344 .split(inner);
345
346 f.render_widget(
348 Paragraph::new(" MOVING").style(Style::default().fg(gray).bg(bg)),
349 rows[0],
350 );
351
352 super::render_path_row(f, rows[1], &self.path_display, fg, bg);
354
355 f.render_widget(
359 Paragraph::new(" DESTINATION").style(Style::default().fg(gray).bg(bg)),
360 rows[3],
361 );
362
363 let input_block = Block::default()
365 .borders(Borders::ALL)
366 .border_style(Style::default().fg(gray))
367 .style(Style::default().bg(bg));
368 let input_inner = input_block.inner(rows[4]);
369 f.render_widget(input_block, rows[4]);
370 self.search_query
371 .render(f, input_inner, Style::default().fg(fg).bg(bg), 0, true);
372
373 let list_items: Vec<ListItem> = if self.results().is_empty() {
375 if self.load_task.is_some() {
376 vec![ListItem::new(" (loading...)").style(Style::default().fg(gray).bg(bg))]
377 } else {
378 vec![ListItem::new(" (no matches)").style(Style::default().fg(gray).bg(bg))]
379 }
380 } else {
381 self.results()
382 .iter()
383 .map(|p| {
384 let display = if *p == VaultPath::root() {
385 " / (vault root)".to_string()
386 } else {
387 format!(" {}", p)
388 };
389 ListItem::new(display).style(Style::default().fg(fg).bg(bg))
390 })
391 .collect()
392 };
393
394 let list_block = Block::default()
395 .borders(Borders::ALL)
396 .border_style(Style::default().fg(gray))
397 .style(Style::default().bg(bg));
398
399 let list = List::new(list_items)
400 .block(list_block)
401 .highlight_style(
402 Style::default()
403 .bg(theme.selection_bg.to_ratatui())
404 .fg(theme.selection_fg.to_ratatui())
405 .add_modifier(Modifier::BOLD),
406 )
407 .highlight_symbol(">> ");
408
409 f.render_stateful_widget(list, rows[5], &mut self.list_state);
410
411 let (status_text, status_style) = match self.dest_validation {
413 ValidationState::Idle => ("", Style::default().bg(bg)),
414 ValidationState::Pending => (" Checking...", Style::default().fg(gray).bg(bg)),
415 ValidationState::Available => (
416 " Available",
417 Style::default().fg(theme.green.to_ratatui()).bg(bg),
418 ),
419 ValidationState::Taken => (
420 " Already exists",
421 Style::default().fg(theme.red.to_ratatui()).bg(bg),
422 ),
423 };
424 f.render_widget(Paragraph::new(status_text).style(status_style), rows[6]);
425
426 super::render_confirm_hint(
428 f,
429 rows[7],
430 " [Enter] Move here",
431 self.dest_validation == ValidationState::Available,
432 fg,
433 gray,
434 bg,
435 );
436
437 if let Some(msg) = &self.error {
439 super::render_error_row(f, rows[8], msg, theme);
440 }
441 }
442}
443
444#[cfg(test)]
449mod tests {
450 use super::*;
451 use kimun_core::VaultConfig;
452 use tokio::sync::mpsc;
453
454 #[test]
457 fn struct_fields_accessible() {
458 fn _check_error_field(d: &MoveDialog) -> Option<&String> {
460 d.error.as_ref()
461 }
462 fn _check_search_query(d: &MoveDialog) -> &str {
464 d.search_query.value()
465 }
466 fn _check_results(d: &MoveDialog) -> &[VaultPath] {
468 d.results()
469 }
470 fn _check_list_state(d: &mut MoveDialog) -> &mut ListState {
472 &mut d.list_state
473 }
474 }
475
476 #[test]
479 fn esc_sends_close_dialog() {
480 use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
481
482 let rt = tokio::runtime::Runtime::new().unwrap();
483 rt.block_on(async {
484 let tmp = std::env::temp_dir().join("kimun_move_esc_test");
485 std::fs::create_dir_all(&tmp).unwrap();
486
487 let vault_result = NoteVault::new(VaultConfig::new(tmp)).await;
488 let Ok(vault) = vault_result else {
489 return;
491 };
492
493 let vault = Arc::new(vault);
494 let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
495 let mut dialog = MoveDialog::new(VaultPath::new("notes/test.md"), vault, &tx);
496
497 let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
498 let state = dialog.handle_key(key, &tx);
499
500 assert_eq!(state, EventState::Consumed);
501 let mut found = false;
504 while let Ok(event) = rx.try_recv() {
505 if matches!(event, AppEvent::CloseOverlay) {
506 found = true;
507 break;
508 }
509 }
510 assert!(found, "expected AppEvent::CloseOverlay in channel");
511 });
512 }
513
514 #[tokio::test]
523 #[ignore = "requires a real vault directory with kimun.sqlite"]
524 async fn new_initial_state() {
525 use std::path::PathBuf;
526
527 let tmp = std::env::temp_dir().join("kimun_move_test_vault");
528 std::fs::create_dir_all(&tmp).unwrap();
529
530 let vault = Arc::new(
531 NoteVault::new(VaultConfig::new(PathBuf::from(&tmp)))
532 .await
533 .expect("vault creation failed"),
534 );
535
536 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel::<AppEvent>();
537 let path = VaultPath::new("notes/projects/kimun.md");
538 let dialog = MoveDialog::new(path, vault, &tx);
539
540 assert!(dialog.search_query.is_empty());
541 assert!(dialog.error.is_none());
542 assert!(dialog.filtered.is_none());
545 }
546}