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, FileOp, OverlayData};
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
112 .send(AppEvent::OverlayData(OverlayData::MoveDirectoriesLoaded(
113 paths,
114 )))
115 .ok();
116 }
117 });
118 self.load_task = Some(handle);
119 }
120
121 fn schedule_filter(&mut self, tx: &AppTx) {
130 if let Some(handle) = self.filter_task.take() {
131 handle.abort();
132 }
133
134 if self.search_query.is_empty() {
135 self.filtered = None;
136 if self.list_state.selected().is_none() && !self.results().is_empty() {
137 self.list_state.select(Some(0));
138 }
139 return;
140 }
141
142 let query = self.search_query.value().to_string();
143 let items: Vec<String> = self.all_dirs.iter().map(|p| p.to_string()).collect();
144 let tx_clone = tx.clone();
145
146 let handle = tokio::spawn(async move {
147 let matched_strs = tokio::task::spawn_blocking(move || {
148 let mut matcher = nucleo::Matcher::new(nucleo::Config::DEFAULT);
149 let pattern = Pattern::parse(&query, CaseMatching::Ignore, Normalization::Smart);
150 let mut matched: Vec<(u32, String)> = items
151 .into_iter()
152 .filter_map(|item| {
153 let haystack = Utf32String::from(item.as_str());
154 pattern
155 .score(haystack.slice(..), &mut matcher)
156 .map(|score| (score, item))
157 })
158 .collect();
159 matched.sort_by_key(|(score, _)| std::cmp::Reverse(*score));
160 matched.into_iter().map(|(_, s)| s).collect::<Vec<_>>()
161 })
162 .await
163 .unwrap_or_default();
164
165 let paths = matched_strs.iter().map(VaultPath::new).collect();
166 tx_clone
167 .send(AppEvent::OverlayData(OverlayData::MoveFilterResults(paths)))
168 .ok();
169 });
170
171 self.filter_task = Some(handle);
172 }
173
174 pub fn spawn_validation(&mut self, tx: &AppTx) {
182 if let Some(handle) = self.validation_task.take() {
183 handle.abort();
184 }
185
186 let Some(idx) = self.list_state.selected() else {
187 self.dest_validation = ValidationState::Idle;
188 return;
189 };
190 let Some(dest_dir) = self.results().get(idx).cloned() else {
191 self.dest_validation = ValidationState::Idle;
192 return;
193 };
194
195 let from = self.path.clone();
196 let vault = Arc::clone(&self.vault);
197 let tx_clone = tx.clone();
198
199 let handle = tokio::spawn(async move {
200 let filename = from.get_parent_path().1;
201 let candidate = if from.is_note() {
202 dest_dir.append(&VaultPath::note_path_from(&filename))
203 } else {
204 dest_dir.append(&VaultPath::new(&filename))
205 };
206 let exists = vault.exists(&candidate).await;
207 tx_clone
208 .send(AppEvent::OverlayData(OverlayData::MoveDestValidation {
209 available: !exists,
210 }))
211 .ok();
212 });
213
214 self.validation_task = Some(handle);
215 self.dest_validation = ValidationState::Pending;
216 }
217
218 pub fn handle_key(&mut self, key: KeyEvent, tx: &AppTx) -> EventState {
225 match key.code {
227 KeyCode::Up => {
228 if let Some(idx) = self.list_state.selected() {
229 self.list_state.select(Some(idx.saturating_sub(1)));
230 self.spawn_validation(tx);
231 }
232 return EventState::Consumed;
233 }
234 KeyCode::Down => {
235 if !self.results().is_empty() {
236 let next = self
237 .list_state
238 .selected()
239 .map_or(0, |i| (i + 1).min(self.results().len() - 1));
240 self.list_state.select(Some(next));
241 self.spawn_validation(tx);
242 }
243 return EventState::Consumed;
244 }
245 _ => {}
246 }
247 if let KeyCode::Char(_) = key.code {
249 let non_shift = key.modifiers - KeyModifiers::SHIFT;
250 if !non_shift.is_empty() {
251 return EventState::Consumed;
252 }
253 }
254 match self.search_query.handle_key(&key) {
255 InputOutcome::Submit => {
256 if self.dest_validation == ValidationState::Taken {
257 return EventState::Consumed;
258 }
259 if let Some(selected_idx) = self.list_state.selected()
260 && selected_idx < self.results().len()
261 {
262 let from = self.path.clone();
263 let dest_dir = self.results()[selected_idx].clone();
264 let filename = from.get_parent_path().1;
265 let new_path = if from.is_note() {
266 dest_dir.append(&VaultPath::note_path_from(&filename))
267 } else {
268 dest_dir.append(&VaultPath::new(&filename))
269 };
270 let vault = Arc::clone(&self.vault);
271 let tx2 = tx.clone();
272 tokio::spawn(async move {
273 let result = vault.rename_entry(&from, &new_path).await;
277 match result {
278 Ok(()) => {
279 tx2.send(AppEvent::FileOp(FileOp::Moved { from, to: new_path }))
280 .ok();
281 }
282 Err(e) => {
283 tx2.send(AppEvent::OverlayData(OverlayData::Error(e.to_string())))
284 .ok();
285 }
286 }
287 });
288 }
289 EventState::Consumed
290 }
291 InputOutcome::Cancel => {
292 tx.send(AppEvent::CloseOverlay).ok();
293 EventState::Consumed
294 }
295 InputOutcome::Changed => {
296 self.schedule_filter(tx);
297 self.dest_validation = ValidationState::Idle;
298 EventState::Consumed
299 }
300 InputOutcome::Consumed => EventState::Consumed,
301 InputOutcome::NotConsumed => EventState::NotConsumed,
302 }
303 }
304}
305
306impl Component for MoveDialog {
311 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, _focused: bool) {
312 let popup_area = crate::components::centered_rect(50, 60, rect);
313
314 let inner = modal_chrome(
315 f,
316 popup_area,
317 theme,
318 ModalSpec {
319 title: Some(" Move "),
320 border: Some(Style::default().fg(theme.fg.to_ratatui())),
321 ..Default::default()
322 },
323 );
324
325 let bg = theme.bg_panel.to_ratatui();
326 let fg = theme.fg.to_ratatui();
327 let gray = theme.gray.to_ratatui();
328
329 let rows = Layout::default()
342 .direction(Direction::Vertical)
343 .constraints([
344 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 }), ])
354 .split(inner);
355
356 f.render_widget(
358 Paragraph::new(" MOVING").style(Style::default().fg(gray).bg(bg)),
359 rows[0],
360 );
361
362 super::render_path_row(f, rows[1], &self.path_display, fg, bg);
364
365 f.render_widget(
369 Paragraph::new(" DESTINATION").style(Style::default().fg(gray).bg(bg)),
370 rows[3],
371 );
372
373 let input_block = Block::default()
375 .borders(Borders::ALL)
376 .border_style(Style::default().fg(gray))
377 .style(Style::default().bg(bg));
378 let input_inner = input_block.inner(rows[4]);
379 f.render_widget(input_block, rows[4]);
380 self.search_query
381 .render(f, input_inner, Style::default().fg(fg).bg(bg), 0, true);
382
383 let list_items: Vec<ListItem> = if self.results().is_empty() {
385 if self.load_task.is_some() {
386 vec![ListItem::new(" (loading...)").style(Style::default().fg(gray).bg(bg))]
387 } else {
388 vec![ListItem::new(" (no matches)").style(Style::default().fg(gray).bg(bg))]
389 }
390 } else {
391 self.results()
392 .iter()
393 .map(|p| {
394 let display = if *p == VaultPath::root() {
395 " / (vault root)".to_string()
396 } else {
397 format!(" {}", p)
398 };
399 ListItem::new(display).style(Style::default().fg(fg).bg(bg))
400 })
401 .collect()
402 };
403
404 let list_block = Block::default()
405 .borders(Borders::ALL)
406 .border_style(Style::default().fg(gray))
407 .style(Style::default().bg(bg));
408
409 let list = List::new(list_items)
410 .block(list_block)
411 .highlight_style(
412 Style::default()
413 .bg(theme.selection_bg.to_ratatui())
414 .fg(theme.selection_fg.to_ratatui())
415 .add_modifier(Modifier::BOLD),
416 )
417 .highlight_symbol(">> ");
418
419 f.render_stateful_widget(list, rows[5], &mut self.list_state);
420
421 let (status_text, status_style) = match self.dest_validation {
423 ValidationState::Idle => ("", Style::default().bg(bg)),
424 ValidationState::Pending => (" Checking...", Style::default().fg(gray).bg(bg)),
425 ValidationState::Available => (
426 " Available",
427 Style::default().fg(theme.green.to_ratatui()).bg(bg),
428 ),
429 ValidationState::Taken => (
430 " Already exists",
431 Style::default().fg(theme.red.to_ratatui()).bg(bg),
432 ),
433 };
434 f.render_widget(Paragraph::new(status_text).style(status_style), rows[6]);
435
436 super::render_confirm_hint(
438 f,
439 rows[7],
440 " [Enter] Move here",
441 self.dest_validation == ValidationState::Available,
442 fg,
443 gray,
444 bg,
445 );
446
447 if let Some(msg) = &self.error {
449 super::render_error_row(f, rows[8], msg, theme);
450 }
451 }
452}
453
454#[cfg(test)]
459mod tests {
460 use super::*;
461 use kimun_core::VaultConfig;
462 use tokio::sync::mpsc;
463
464 #[test]
467 fn struct_fields_accessible() {
468 fn _check_error_field(d: &MoveDialog) -> Option<&String> {
470 d.error.as_ref()
471 }
472 fn _check_search_query(d: &MoveDialog) -> &str {
474 d.search_query.value()
475 }
476 fn _check_results(d: &MoveDialog) -> &[VaultPath] {
478 d.results()
479 }
480 fn _check_list_state(d: &mut MoveDialog) -> &mut ListState {
482 &mut d.list_state
483 }
484 }
485
486 #[test]
489 fn esc_sends_close_dialog() {
490 use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
491
492 let rt = tokio::runtime::Runtime::new().unwrap();
493 rt.block_on(async {
494 let tmp = std::env::temp_dir().join("kimun_move_esc_test");
495 std::fs::create_dir_all(&tmp).unwrap();
496
497 let vault_result =
498 NoteVault::new(VaultConfig::new(crate::test_support::sys(tmp))).await;
499 let Ok(vault) = vault_result else {
500 return;
502 };
503
504 let vault = Arc::new(vault);
505 let (tx, mut rx) = mpsc::unbounded_channel::<AppEvent>();
506 let mut dialog = MoveDialog::new(VaultPath::new("notes/test.md"), vault, &tx);
507
508 let key = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
509 let state = dialog.handle_key(key, &tx);
510
511 assert_eq!(state, EventState::Consumed);
512 let mut found = false;
515 while let Ok(event) = rx.try_recv() {
516 if matches!(event, AppEvent::CloseOverlay) {
517 found = true;
518 break;
519 }
520 }
521 assert!(found, "expected AppEvent::CloseOverlay in channel");
522 });
523 }
524
525 #[tokio::test]
534 #[ignore = "requires a real vault directory with kimun.sqlite"]
535 async fn new_initial_state() {
536 use std::path::PathBuf;
537
538 let tmp = std::env::temp_dir().join("kimun_move_test_vault");
539 std::fs::create_dir_all(&tmp).unwrap();
540
541 let vault = Arc::new(
542 NoteVault::new(VaultConfig::new(crate::test_support::sys(PathBuf::from(
543 &tmp,
544 ))))
545 .await
546 .expect("vault creation failed"),
547 );
548
549 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel::<AppEvent>();
550 let path = VaultPath::new("notes/projects/kimun.md");
551 let dialog = MoveDialog::new(path, vault, &tx);
552
553 assert!(dialog.search_query.is_empty());
554 assert!(dialog.error.is_none());
555 assert!(dialog.filtered.is_none());
558 }
559}