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::{Color, Modifier, Style};
11use ratatui::widgets::{Block, Borders, Clear, 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::single_line_input::{InputOutcome, SingleLineInput};
19use crate::settings::themes::Theme;
20
21pub struct MoveDialog {
33 pub path: VaultPath,
35 pub vault: Arc<NoteVault>,
37 pub path_display: String,
39 pub search_query: SingleLineInput,
41 pub all_dirs: Vec<VaultPath>,
43 pub load_task: Option<JoinHandle<()>>,
45 pub filter_task: Option<JoinHandle<()>>,
47 pub filtered: Option<Vec<VaultPath>>,
49 pub list_state: ListState,
51 pub dest_validation: ValidationState,
53 pub validation_task: Option<JoinHandle<()>>,
55 pub error: Option<String>,
57}
58
59impl MoveDialog {
60 pub fn new(path: VaultPath, vault: Arc<NoteVault>, tx: &AppTx) -> Self {
64 let path_display = format!(" {}", path);
65 let mut dialog = Self {
66 path,
67 vault,
68 path_display,
69 search_query: SingleLineInput::new(),
70 all_dirs: vec![],
71 load_task: None,
72 filter_task: None,
73 filtered: None,
74 list_state: ListState::default(),
75 dest_validation: ValidationState::Idle,
76 validation_task: None,
77 error: None,
78 };
79 dialog.schedule_load(tx);
80 dialog
81 }
82
83 pub fn results(&self) -> &[VaultPath] {
88 self.filtered.as_deref().unwrap_or(&self.all_dirs)
89 }
90
91 fn schedule_load(&mut self, tx: &AppTx) {
98 let vault = Arc::clone(&self.vault);
99 let tx_clone = tx.clone();
100 let handle = tokio::spawn(async move {
101 let result = tokio::task::spawn_blocking(move || {
102 vault.get_directories(&VaultPath::root(), true)
103 })
104 .await;
105 if let Ok(Ok(dirs)) = result {
106 let mut paths: Vec<VaultPath> = std::iter::once(VaultPath::root())
107 .chain(dirs.into_iter().map(|d| d.path))
108 .collect();
109 paths.sort();
110 tx_clone.send(AppEvent::MoveDirectoriesLoaded(paths)).ok();
111 }
112 });
113 self.load_task = Some(handle);
114 }
115
116 fn schedule_filter(&mut self, tx: &AppTx) {
125 if let Some(handle) = self.filter_task.take() {
126 handle.abort();
127 }
128
129 if self.search_query.is_empty() {
130 self.filtered = None;
131 if self.list_state.selected().is_none() && !self.results().is_empty() {
132 self.list_state.select(Some(0));
133 }
134 return;
135 }
136
137 let query = self.search_query.value().to_string();
138 let items: Vec<String> = self.all_dirs.iter().map(|p| p.to_string()).collect();
139 let tx_clone = tx.clone();
140
141 let handle = tokio::spawn(async move {
142 let matched_strs = tokio::task::spawn_blocking(move || {
143 let mut matcher = nucleo::Matcher::new(nucleo::Config::DEFAULT);
144 let pattern = Pattern::parse(&query, CaseMatching::Ignore, Normalization::Smart);
145 let mut matched: Vec<(u32, String)> = items
146 .into_iter()
147 .filter_map(|item| {
148 let haystack = Utf32String::from(item.as_str());
149 pattern
150 .score(haystack.slice(..), &mut matcher)
151 .map(|score| (score, item))
152 })
153 .collect();
154 matched.sort_by_key(|(score, _)| std::cmp::Reverse(*score));
155 matched.into_iter().map(|(_, s)| s).collect::<Vec<_>>()
156 })
157 .await
158 .unwrap_or_default();
159
160 let paths = matched_strs.iter().map(VaultPath::new).collect();
161 tx_clone.send(AppEvent::MoveFilterResults(paths)).ok();
162 });
163
164 self.filter_task = Some(handle);
165 }
166
167 pub fn spawn_validation(&mut self, tx: &AppTx) {
175 if let Some(handle) = self.validation_task.take() {
176 handle.abort();
177 }
178
179 let Some(idx) = self.list_state.selected() else {
180 self.dest_validation = ValidationState::Idle;
181 return;
182 };
183 let Some(dest_dir) = self.results().get(idx).cloned() else {
184 self.dest_validation = ValidationState::Idle;
185 return;
186 };
187
188 let from = self.path.clone();
189 let vault = Arc::clone(&self.vault);
190 let tx_clone = tx.clone();
191
192 let handle = tokio::spawn(async move {
193 let filename = from.get_parent_path().1;
194 let candidate = if from.is_note() {
195 dest_dir.append(&VaultPath::note_path_from(&filename))
196 } else {
197 dest_dir.append(&VaultPath::new(&filename))
198 };
199 let exists = vault.exists(&candidate).await;
200 tx_clone
201 .send(AppEvent::MoveDestValidation { available: !exists })
202 .ok();
203 });
204
205 self.validation_task = Some(handle);
206 self.dest_validation = ValidationState::Pending;
207 }
208
209 pub fn handle_key(&mut self, key: KeyEvent, tx: &AppTx) -> EventState {
216 match key.code {
218 KeyCode::Up => {
219 if let Some(idx) = self.list_state.selected() {
220 self.list_state.select(Some(idx.saturating_sub(1)));
221 self.spawn_validation(tx);
222 }
223 return EventState::Consumed;
224 }
225 KeyCode::Down => {
226 if !self.results().is_empty() {
227 let next = self
228 .list_state
229 .selected()
230 .map_or(0, |i| (i + 1).min(self.results().len() - 1));
231 self.list_state.select(Some(next));
232 self.spawn_validation(tx);
233 }
234 return EventState::Consumed;
235 }
236 _ => {}
237 }
238 if let KeyCode::Char(_) = key.code {
240 let non_shift = key.modifiers - KeyModifiers::SHIFT;
241 if !non_shift.is_empty() {
242 return EventState::Consumed;
243 }
244 }
245 match self.search_query.handle_key(&key) {
246 InputOutcome::Submit => {
247 if self.dest_validation == ValidationState::Taken {
248 return EventState::Consumed;
249 }
250 if let Some(selected_idx) = self.list_state.selected()
251 && selected_idx < self.results().len()
252 {
253 let from = self.path.clone();
254 let dest_dir = self.results()[selected_idx].clone();
255 let filename = from.get_parent_path().1;
256 let new_path = if from.is_note() {
257 dest_dir.append(&VaultPath::note_path_from(&filename))
258 } else {
259 dest_dir.append(&VaultPath::new(&filename))
260 };
261 let vault = Arc::clone(&self.vault);
262 let tx2 = tx.clone();
263 tokio::spawn(async move {
264 let result = if from.is_note() {
268 vault.rename_note(&from, &new_path).await
269 } else {
270 vault.rename_directory(&from, &new_path).await
271 };
272 match result {
273 Ok(()) => {
274 tx2.send(AppEvent::EntryMoved { from, to: new_path }).ok();
275 }
276 Err(e) => {
277 tx2.send(AppEvent::DialogError(e.to_string())).ok();
278 }
279 }
280 });
281 }
282 EventState::Consumed
283 }
284 InputOutcome::Cancel => {
285 tx.send(AppEvent::CloseDialog).ok();
286 EventState::Consumed
287 }
288 InputOutcome::Changed => {
289 self.schedule_filter(tx);
290 self.dest_validation = ValidationState::Idle;
291 EventState::Consumed
292 }
293 InputOutcome::Consumed => EventState::Consumed,
294 InputOutcome::NotConsumed => EventState::NotConsumed,
295 }
296 }
297}
298
299impl Component for MoveDialog {
304 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, _focused: bool) {
305 let popup_area = super::centered_rect(50, 60, rect);
306
307 f.render_widget(Clear, popup_area);
309
310 let outer_block = Block::default()
312 .title(" Move ")
313 .borders(Borders::ALL)
314 .border_style(Style::default().fg(theme.fg.to_ratatui()))
315 .style(theme.panel_style());
316 let inner = outer_block.inner(popup_area);
317 f.render_widget(outer_block, popup_area);
318
319 let bg = theme.bg_panel.to_ratatui();
320 let fg = theme.fg.to_ratatui();
321 let fg_muted = theme.fg_muted.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(fg_muted).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(fg_muted).bg(bg)),
364 rows[3],
365 );
366
367 let input_block = Block::default()
369 .borders(Borders::ALL)
370 .border_style(Style::default().fg(fg_muted))
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(fg_muted).bg(bg))]
381 } else {
382 vec![ListItem::new(" (no matches)").style(Style::default().fg(fg_muted).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(fg_muted))
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.bg_selected.to_ratatui())
408 .fg(theme.fg_selected.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(fg_muted).bg(bg)),
419 ValidationState::Available => (" Available", Style::default().fg(Color::Green).bg(bg)),
420 ValidationState::Taken => (" Already exists", Style::default().fg(Color::Red).bg(bg)),
421 };
422 f.render_widget(Paragraph::new(status_text).style(status_style), rows[6]);
423
424 super::render_confirm_hint(
426 f,
427 rows[7],
428 " [Enter] Move here",
429 self.dest_validation == ValidationState::Available,
430 fg,
431 fg_muted,
432 bg,
433 );
434
435 if let Some(msg) = &self.error {
437 super::render_error_row(f, rows[8], msg, bg);
438 }
439 }
440}
441
442#[cfg(test)]
447mod tests {
448 use super::*;
449 use kimun_core::VaultConfig;
450 use tokio::sync::mpsc;
451
452 #[test]
455 fn struct_fields_accessible() {
456 fn _check_error_field(d: &MoveDialog) -> Option<&String> {
458 d.error.as_ref()
459 }
460 fn _check_search_query(d: &MoveDialog) -> &str {
462 d.search_query.value()
463 }
464 fn _check_results(d: &MoveDialog) -> &[VaultPath] {
466 d.results()
467 }
468 fn _check_list_state(d: &mut MoveDialog) -> &mut ListState {
470 &mut d.list_state
471 }
472 }
473
474 #[test]
477 fn esc_sends_close_dialog() {
478 use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
479
480 let rt = tokio::runtime::Runtime::new().unwrap();
481 rt.block_on(async {
482 let tmp = std::env::temp_dir().join("kimun_move_esc_test");
483 std::fs::create_dir_all(&tmp).unwrap();
484
485 let vault_result = NoteVault::new(VaultConfig::new(tmp)).await;
486 let Ok(vault) = vault_result else {
487 return;
489 };
490
491 let vault = Arc::new(vault);
492 let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
493 let mut dialog = MoveDialog::new(VaultPath::new("notes/test.md"), vault, &tx);
494
495 let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
496 let state = dialog.handle_key(key, &tx);
497
498 assert_eq!(state, EventState::Consumed);
499 let mut found = false;
502 while let Ok(event) = rx.try_recv() {
503 if matches!(event, AppEvent::CloseDialog) {
504 found = true;
505 break;
506 }
507 }
508 assert!(found, "expected AppEvent::CloseDialog in channel");
509 });
510 }
511
512 #[tokio::test]
521 #[ignore = "requires a real vault directory with kimun.sqlite"]
522 async fn new_initial_state() {
523 use std::path::PathBuf;
524
525 let tmp = std::env::temp_dir().join("kimun_move_test_vault");
526 std::fs::create_dir_all(&tmp).unwrap();
527
528 let vault = Arc::new(
529 NoteVault::new(VaultConfig::new(PathBuf::from(&tmp)))
530 .await
531 .expect("vault creation failed"),
532 );
533
534 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel::<AppEvent>();
535 let path = VaultPath::new("notes/projects/kimun.md");
536 let dialog = MoveDialog::new(path, vault, &tx);
537
538 assert!(dialog.search_query.is_empty());
539 assert!(dialog.error.is_none());
540 assert!(dialog.filtered.is_none());
543 }
544}