1use std::sync::mpsc::Receiver;
2
3use kimun_core::nfs::VaultPath;
4use kimun_core::{ResultType, SearchResult};
5use nucleo::Matcher;
6use nucleo::pattern::{CaseMatching, Normalization, Pattern};
7use ratatui::Frame;
8use ratatui::crossterm::event::{KeyCode, KeyModifiers};
9use ratatui::layout::Rect;
10use ratatui::style::{Modifier, Style};
11use ratatui::text::{Line, Span, Text};
12use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
13
14use crate::components::Component;
15use crate::components::event_state::EventState;
16use crate::components::events::AppEvent;
17use crate::components::events::{AppTx, InputEvent};
18use crate::components::single_line_input::{InputOutcome, SingleLineInput};
19use crate::keys::KeyBindings;
20use crate::keys::action_shortcuts::ActionShortcuts;
21use crate::keys::key_event_to_combo;
22use crate::settings::icons::Icons;
23use crate::settings::themes::Theme;
24use crate::settings::{SortFieldSetting, SortOrderSetting};
25
26#[derive(Clone, Copy, PartialEq)]
31pub enum SortField {
32 Name,
33 Title,
34}
35
36#[derive(Clone, Copy, PartialEq)]
37pub enum SortOrder {
38 Ascending,
39 Descending,
40}
41
42impl From<SortFieldSetting> for SortField {
43 fn from(s: SortFieldSetting) -> Self {
44 match s {
45 SortFieldSetting::Name => Self::Name,
46 SortFieldSetting::Title => Self::Title,
47 }
48 }
49}
50
51impl From<SortOrderSetting> for SortOrder {
52 fn from(s: SortOrderSetting) -> Self {
53 match s {
54 SortOrderSetting::Ascending => Self::Ascending,
55 SortOrderSetting::Descending => Self::Descending,
56 }
57 }
58}
59
60impl SortField {
61 pub fn label(self) -> char {
62 match self {
63 Self::Name => 'N',
64 Self::Title => 'T',
65 }
66 }
67
68 pub fn cycle(self) -> Self {
69 match self {
70 Self::Name => Self::Title,
71 Self::Title => Self::Name,
72 }
73 }
74}
75
76impl SortOrder {
77 pub fn label(self) -> char {
78 match self {
79 Self::Ascending => '↑',
80 Self::Descending => '↓',
81 }
82 }
83
84 pub fn toggle(self) -> Self {
85 match self {
86 Self::Ascending => Self::Descending,
87 Self::Descending => Self::Ascending,
88 }
89 }
90}
91
92#[derive(Clone)]
97pub enum FileListEntry {
98 Up {
99 parent: VaultPath,
100 },
101 Note {
102 path: VaultPath,
103 title: String,
104 filename: String,
105 journal_date: Option<String>,
106 },
107 Directory {
108 path: VaultPath,
109 name: String,
110 },
111 Attachment {
112 path: VaultPath,
113 filename: String,
114 },
115 CreateNote {
116 filename: String,
117 path: VaultPath,
118 },
119}
120
121impl FileListEntry {
122 pub fn from_result(result: SearchResult, journal_date: Option<String>) -> Self {
123 let filename = result.path.get_parent_path().1;
124 match result.rtype {
125 ResultType::Note(data) => {
126 let title = if data.title.trim().is_empty() {
127 "<no title>".to_string()
128 } else {
129 data.title
130 };
131 Self::Note {
132 path: result.path,
133 title,
134 filename,
135 journal_date,
136 }
137 }
138 ResultType::Directory => Self::Directory {
139 path: result.path,
140 name: filename,
141 },
142 ResultType::Attachment => Self::Attachment {
143 path: result.path,
144 filename,
145 },
146 }
147 }
148
149 pub fn path(&self) -> &VaultPath {
150 match self {
151 Self::Up { parent } => parent,
152 Self::Note { path, .. } => path,
153 Self::Directory { path, .. } => path,
154 Self::Attachment { path, .. } => path,
155 Self::CreateNote { path, .. } => path,
156 }
157 }
158
159 pub fn search_str(&self) -> Option<String> {
160 match self {
161 Self::Up { .. } => None,
162 Self::Note {
163 title, filename, ..
164 } => Some(format!("{} {}", title, filename)),
165 Self::Directory { name, .. } => Some(name.clone()),
166 Self::Attachment { filename, .. } => Some(filename.clone()),
167 Self::CreateNote { filename, .. } => Some(filename.clone()),
168 }
169 }
170
171 fn sort_key(&self, field: SortField) -> String {
173 match self {
174 Self::Up { .. } => String::new(),
175 Self::Note {
176 title, filename, ..
177 } => match field {
178 SortField::Title => title.to_lowercase(),
179 SortField::Name => filename.to_lowercase(),
180 },
181 Self::Directory { name, .. } => name.to_lowercase(),
182 Self::Attachment { filename, .. } => filename.to_lowercase(),
183 Self::CreateNote { filename, .. } => filename.to_lowercase(),
184 }
185 }
186
187 pub fn visual_height(&self) -> u16 {
189 match self {
190 Self::Note { journal_date, .. } => {
191 if journal_date.is_some() {
192 3
193 } else {
194 2
195 }
196 }
197 _ => 1,
198 }
199 }
200
201 fn to_list_item(&self, theme: &Theme, icons: &Icons) -> ListItem<'static> {
202 let lines: Vec<Line> = match self {
203 Self::Up { .. } => vec![Line::from(Span::styled(
204 format!("{} [UP] ..", icons.directory_up),
205 Style::default().fg(theme.fg_muted.to_ratatui()),
206 ))],
207 Self::Note {
208 title,
209 filename,
210 journal_date,
211 ..
212 } => {
213 let mut lines = vec![];
214 if let Some(date) = journal_date {
215 lines.push(Line::from(format!("{} {}", icons.journal, title)));
216 lines.push(Line::from(Span::styled(
217 format!(" {}", date),
218 Style::default().fg(theme.color_journal_date.to_ratatui()),
219 )));
220 } else {
221 lines.push(Line::from(format!("{} {}", icons.note, title)));
222 }
223 lines.push(Line::from(Span::styled(
224 format!(" {}", filename),
225 Style::default()
226 .add_modifier(Modifier::ITALIC)
227 .fg(theme.fg_secondary.to_ratatui()),
228 )));
229 lines
230 }
231 Self::Directory { name, .. } => vec![Line::from(Span::styled(
232 format!("{} {}", icons.directory, name),
233 Style::default().fg(theme.color_directory.to_ratatui()),
234 ))],
235 Self::Attachment { filename, .. } => vec![Line::from(Span::styled(
236 format!("{} {}", icons.attachment, filename),
237 Style::default()
238 .add_modifier(Modifier::ITALIC)
239 .fg(theme.fg_secondary.to_ratatui()),
240 ))],
241 Self::CreateNote { filename, .. } => vec![Line::from(Span::styled(
242 format!("+ Create: {}", filename),
243 Style::default().fg(theme.accent.to_ratatui()),
244 ))],
245 };
246 ListItem::new(Text::from(lines))
247 }
248}
249
250#[derive(Clone)]
255struct MatchEntry {
256 idx: usize,
257 text: String,
258}
259
260impl AsRef<str> for MatchEntry {
261 fn as_ref(&self) -> &str {
262 &self.text
263 }
264}
265
266pub struct FileListComponent {
271 pub entries: Vec<FileListEntry>,
272 pub loading: bool,
273 display_indices: Option<Vec<usize>>,
274 list_state: ListState,
275 pub search_query: SingleLineInput,
277 filter_rx: Option<Receiver<Vec<usize>>>,
278 filter_task: Option<tokio::task::JoinHandle<()>>,
279 pub sort_field: SortField,
281 pub sort_order: SortOrder,
282 create_entry: Option<FileListEntry>,
285 key_bindings: KeyBindings,
287 icons: Icons,
289}
290
291impl FileListComponent {
292 pub fn new(key_bindings: KeyBindings, icons: Icons) -> Self {
293 Self {
294 entries: Vec::new(),
295 loading: false,
296 display_indices: None,
297 list_state: ListState::default(),
298 search_query: SingleLineInput::new(),
299 filter_rx: None,
300 filter_task: None,
301 sort_field: SortField::Name,
302 sort_order: SortOrder::Ascending,
303 create_entry: None,
304 key_bindings,
305 icons,
306 }
307 }
308
309 pub fn set_create_entry(&mut self, entry: Option<FileListEntry>) {
310 self.create_entry = entry;
311 self.reset_selection();
312 }
313
314 pub fn is_empty(&self) -> bool {
315 self.entries.is_empty()
316 }
317
318 pub fn push_entry(&mut self, entry: FileListEntry) {
319 if matches!(
320 entry,
321 FileListEntry::Attachment { .. } | FileListEntry::CreateNote { .. }
322 ) {
323 return;
324 }
325 self.entries.push(entry);
326 if self.display_indices.is_none() && self.list_state.selected().is_none() {
327 self.list_state.select(Some(0));
328 }
329 }
330
331 pub fn finalize_sort(&mut self) {
333 self.apply_sort();
334 }
335
336 pub fn add_up_entry(&mut self, parent: VaultPath) {
337 self.entries.insert(0, FileListEntry::Up { parent });
338 self.list_state.select(Some(0));
339 }
340
341 pub fn prepend_create_entry(&mut self, entry: FileListEntry) {
342 self.display_indices = None;
344 self.entries.insert(0, entry);
345 self.list_state.select(Some(0));
346 }
347
348 pub fn clear(&mut self) {
349 if let Some(handle) = self.filter_task.take() {
350 handle.abort();
351 }
352 self.entries.clear();
353 self.display_indices = None;
354 self.filter_rx = None;
355 self.search_query.clear();
356 self.create_entry = None;
357 self.list_state.select(None);
358 self.loading = false;
359 }
360
361 fn apply_sort(&mut self) {
363 let up_count = self
364 .entries
365 .iter()
366 .take_while(|e| matches!(e, FileListEntry::Up { .. }))
367 .count();
368 let field = self.sort_field;
369 let order = self.sort_order;
370 self.entries[up_count..].sort_by(|a, b| {
371 let ka = a.sort_key(field);
372 let kb = b.sort_key(field);
373 match order {
374 SortOrder::Ascending => ka.cmp(&kb),
375 SortOrder::Descending => kb.cmp(&ka),
376 }
377 });
378 }
379
380 fn set_sort(&mut self, field: SortField, order: SortOrder, tx: AppTx) {
381 self.sort_field = field;
382 self.sort_order = order;
383 self.apply_sort();
384 if !self.search_query.is_empty() {
386 self.schedule_filter(tx);
387 } else {
388 self.display_indices = None;
389 self.reset_selection();
390 }
391 }
392
393 fn schedule_filter(&mut self, tx: AppTx) {
394 if self.search_query.is_empty() {
395 self.display_indices = None;
396 self.filter_rx = None;
397 self.reset_selection();
398 return;
399 }
400
401 let candidates: Vec<MatchEntry> = self
402 .entries
403 .iter()
404 .enumerate()
405 .filter_map(|(i, e)| e.search_str().map(|text| MatchEntry { idx: i, text }))
406 .collect();
407
408 let query = self.search_query.value().to_string();
409 let (result_tx, result_rx) = std::sync::mpsc::channel();
410 self.filter_rx = Some(result_rx);
411
412 if let Some(handle) = self.filter_task.take() {
413 handle.abort();
414 }
415
416 let handle = tokio::spawn(async move {
417 let indices = tokio::task::spawn_blocking(move || {
418 let mut matcher = Matcher::new(nucleo::Config::DEFAULT);
419 let pattern = Pattern::parse(&query, CaseMatching::Ignore, Normalization::Smart);
420 pattern
421 .match_list(candidates, &mut matcher)
422 .into_iter()
423 .map(|(e, _)| e.idx)
424 .collect::<Vec<usize>>()
425 })
426 .await
427 .unwrap_or_default();
428
429 result_tx.send(indices).ok();
430 tx.send(AppEvent::Redraw).ok();
431 });
432 self.filter_task = Some(handle);
433 }
434
435 pub fn poll_filter(&mut self) {
436 let Some(rx) = &self.filter_rx else { return };
437 match rx.try_recv() {
438 Ok(indices) => {
439 let up_indices: Vec<usize> = self
440 .entries
441 .iter()
442 .enumerate()
443 .filter(|(_, e)| matches!(e, FileListEntry::Up { .. }))
444 .map(|(i, _)| i)
445 .collect();
446 let mut combined = up_indices;
447 combined.extend(indices);
448 self.display_indices = Some(combined);
449 self.filter_rx = None;
450 self.reset_selection();
451 }
452 Err(std::sync::mpsc::TryRecvError::Disconnected) => {
453 self.filter_rx = None;
454 }
455 Err(std::sync::mpsc::TryRecvError::Empty) => {}
456 }
457 }
458
459 pub fn display_len(&self) -> usize {
460 let base = match &self.display_indices {
461 None => self.entries.len(),
462 Some(v) => v.len(),
463 };
464 base + usize::from(self.create_entry.is_some())
465 }
466
467 pub fn len(&self) -> usize {
469 self.display_len()
470 }
471
472 pub fn note_count(&self) -> usize {
474 match &self.display_indices {
475 None => self
476 .entries
477 .iter()
478 .filter(|e| matches!(e, FileListEntry::Note { .. }))
479 .count(),
480 Some(indices) => indices
481 .iter()
482 .filter(|&&i| matches!(self.entries.get(i), Some(FileListEntry::Note { .. })))
483 .count(),
484 }
485 }
486
487 fn reset_selection(&mut self) {
488 self.list_state.select(if self.display_len() > 0 {
489 Some(0)
490 } else {
491 None
492 });
493 }
494
495 pub fn scroll_up(&mut self) {
496 let offset = self.list_state.offset();
497 if offset > 0 {
498 *self.list_state.offset_mut() = offset - 1;
499 if let Some(sel) = self.list_state.selected()
500 && sel > 0
501 {
502 self.list_state.select(Some(sel - 1));
503 }
504 }
505 }
506
507 pub fn scroll_down(&mut self) {
508 let len = self.display_len();
509 let offset = self.list_state.offset();
510 if len > 0 && offset + 1 < len {
511 *self.list_state.offset_mut() = offset + 1;
512 if let Some(sel) = self.list_state.selected()
513 && sel + 1 < len
514 {
515 self.list_state.select(Some(sel + 1));
516 }
517 }
518 }
519
520 pub fn select_next(&mut self) {
521 let len = self.display_len();
522 if len == 0 {
523 return;
524 }
525 let cur = self.list_state.selected().unwrap_or(0);
526 self.list_state.select(Some((cur + 1) % len));
527 }
528
529 pub fn select_prev(&mut self) {
530 let len = self.display_len();
531 if len == 0 {
532 return;
533 }
534 let cur = self.list_state.selected().unwrap_or(0);
535 self.list_state
536 .select(Some(if cur == 0 { len - 1 } else { cur - 1 }));
537 }
538
539 pub fn selected_display_idx(&self) -> Option<usize> {
541 self.list_state.selected()
542 }
543
544 pub fn select_at_visual_row(&mut self, rel_row: u16) -> Option<usize> {
548 let idx = self.display_idx_at_row(rel_row)?;
549 self.list_state.select(Some(idx));
550 Some(idx)
551 }
552
553 pub fn selected_entry(&self) -> Option<&FileListEntry> {
554 let display_idx = self.list_state.selected()?;
555 if self.create_entry.is_some() {
556 if display_idx == 0 {
557 return self.create_entry.as_ref();
558 }
559 let adjusted = display_idx - 1;
560 let entry_idx = match &self.display_indices {
561 None => adjusted,
562 Some(v) => *v.get(adjusted)?,
563 };
564 return self.entries.get(entry_idx);
565 }
566 let entry_idx = match &self.display_indices {
567 None => display_idx,
568 Some(v) => *v.get(display_idx)?,
569 };
570 self.entries.get(entry_idx)
571 }
572
573 pub fn activate_selected(&self, tx: &AppTx) {
574 let Some(display_idx) = self.list_state.selected() else {
575 return;
576 };
577 if self.create_entry.is_some() && display_idx == 0 {
578 if let Some(entry) = &self.create_entry {
579 tx.send(AppEvent::OpenPath(entry.path().clone())).ok();
580 }
581 return;
582 }
583 let adjusted = if self.create_entry.is_some() {
584 display_idx - 1
585 } else {
586 display_idx
587 };
588 let entry_idx = match &self.display_indices {
589 None => adjusted,
590 Some(v) => match v.get(adjusted) {
591 Some(&i) => i,
592 None => return,
593 },
594 };
595 tx.send(AppEvent::OpenPath(self.entries[entry_idx].path().clone()))
596 .ok();
597 }
598
599 fn display_idx_at_row(&self, row: u16) -> Option<usize> {
600 let offset = self.list_state.offset();
601 let len = self.display_len();
602 let mut y = 0u16;
603 for display_idx in offset..len {
604 let h = if self.create_entry.is_some() && display_idx == 0 {
605 self.create_entry
606 .as_ref()
607 .map(|e| e.visual_height())
608 .unwrap_or(1)
609 } else {
610 let adjusted = if self.create_entry.is_some() {
611 display_idx - 1
612 } else {
613 display_idx
614 };
615 let entry_idx = match &self.display_indices {
616 None => adjusted,
617 Some(v) => v.get(adjusted).copied()?,
618 };
619 self.entries
620 .get(entry_idx)
621 .map(|e| e.visual_height())
622 .unwrap_or(1)
623 };
624 if row < y + h {
625 return Some(display_idx);
626 }
627 y += h;
628 }
629 None
630 }
631
632 fn header_title(&self) -> String {
633 format!(" [{}{}]", self.sort_field.label(), self.sort_order.label())
634 }
635}
636
637impl Component for FileListComponent {
638 fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
639 let InputEvent::Key(key) = event else {
642 return EventState::NotConsumed;
643 };
644 if let Some(combo) = key_event_to_combo(key) {
646 match self.key_bindings.get_action(&combo) {
647 Some(ActionShortcuts::CycleSortField) => {
650 let field = self.sort_field.cycle();
651 self.set_sort(field, self.sort_order, tx.clone());
652 return EventState::Consumed;
653 }
654 Some(ActionShortcuts::SortReverseOrder) => {
655 let order = self.sort_order.toggle();
656 self.set_sort(self.sort_field, order, tx.clone());
657 return EventState::Consumed;
658 }
659 Some(ActionShortcuts::FileOperations) => {
660 if let Some(entry) = self.selected_entry()
661 && !matches!(entry, FileListEntry::Up { .. })
662 {
663 tx.send(AppEvent::ShowFileOpsMenu(entry.path().clone()))
664 .ok();
665 return EventState::Consumed;
666 }
667 return EventState::NotConsumed;
668 }
669 _ => {}
670 }
671 }
672 match key.code {
674 KeyCode::Up => {
675 self.select_prev();
676 return EventState::Consumed;
677 }
678 KeyCode::Down => {
679 self.select_next();
680 return EventState::Consumed;
681 }
682 _ => {}
683 }
684 if let KeyCode::Char(_) = key.code {
686 let non_shift = key.modifiers - KeyModifiers::SHIFT;
687 if !non_shift.is_empty() {
688 return EventState::Consumed;
689 }
690 }
691 match self.search_query.handle_key(key) {
692 InputOutcome::Submit => {
693 self.activate_selected(tx);
694 EventState::Consumed
695 }
696 InputOutcome::Changed => {
697 self.schedule_filter(tx.clone());
698 EventState::Consumed
699 }
700 InputOutcome::Consumed | InputOutcome::Cancel => EventState::Consumed,
701 InputOutcome::NotConsumed => EventState::NotConsumed,
702 }
703 }
704
705 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
706 self.poll_filter();
707 let title = self.header_title();
708
709 let bg_even = theme.bg.to_ratatui();
710 let bg_odd = theme.bg_panel.to_ratatui();
711
712 let entry_iter: Box<dyn Iterator<Item = &FileListEntry>> = match &self.display_indices {
713 None => Box::new(self.entries.iter()),
714 Some(indices) => Box::new(indices.iter().map(|&i| &self.entries[i])),
715 };
716 let create_iter: Box<dyn Iterator<Item = &FileListEntry>> = match &self.create_entry {
717 Some(e) => Box::new(std::iter::once(e)),
718 None => Box::new(std::iter::empty()),
719 };
720 let items: Vec<ListItem> = create_iter
721 .chain(entry_iter)
722 .enumerate()
723 .map(|(i, e)| {
724 let bg = if i % 2 == 0 { bg_even } else { bg_odd };
725 e.to_list_item(theme, &self.icons)
726 .style(Style::default().bg(bg))
727 })
728 .collect();
729
730 let border_style = theme.border_style(focused);
731
732 let make_block = || {
733 Block::default()
734 .title(title.as_str())
735 .borders(Borders::ALL)
736 .border_style(border_style)
737 .style(theme.panel_style())
738 };
739
740 let has_content = self
741 .entries
742 .iter()
743 .any(|e| !matches!(e, FileListEntry::Up { .. }));
744 if self.loading && !has_content {
745 let loading = Paragraph::new("Loading…")
746 .style(
747 Style::default()
748 .fg(theme.fg_muted.to_ratatui())
749 .bg(theme.bg_panel.to_ratatui()),
750 )
751 .block(make_block());
752 f.render_widget(loading, rect);
753 } else {
754 let list = List::new(items).block(make_block()).highlight_style(
755 Style::default()
756 .fg(theme.fg_selected.to_ratatui())
757 .bg(theme.bg_selected.to_ratatui()),
758 );
759 f.render_stateful_widget(list, rect, &mut self.list_state);
760 }
761 }
762
763 fn hint_shortcuts(&self) -> Vec<(String, String)> {
764 [
765 (ActionShortcuts::FocusEditor, "editor \u{2192}"),
766 (ActionShortcuts::CycleSortField, "cycle sort"),
767 (ActionShortcuts::SortReverseOrder, "reverse"),
768 (ActionShortcuts::FileOperations, "file ops"),
769 ]
770 .iter()
771 .filter_map(|(action, label)| {
772 self.key_bindings
773 .first_combo_for(action)
774 .map(|k| (k, label.to_string()))
775 })
776 .collect()
777 }
778}
779
780#[cfg(test)]
781mod tests {
782 use kimun_core::nfs::VaultPath;
783
784 use super::*;
785
786 fn make_tx() -> AppTx {
787 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel::<AppEvent>();
788 tx
789 }
790
791 fn make_list() -> FileListComponent {
792 FileListComponent::new(
793 crate::keys::KeyBindings::empty(),
794 crate::settings::icons::Icons::new(true),
795 )
796 }
797
798 #[tokio::test]
799 async fn schedule_filter_stores_handle_and_cancels_previous() {
800 let tx = make_tx();
801 let mut list = FileListComponent::new(
802 crate::keys::KeyBindings::empty(),
803 crate::settings::icons::Icons::new(true),
804 );
805 for i in 0..20 {
806 list.push_entry(make_note(&format!("{i}.md"), &format!("Note {i}")));
807 }
808
809 list.search_query.set_value("note");
810 list.schedule_filter(tx.clone());
811
812 assert!(
814 list.filter_task.is_some(),
815 "filter_task should be Some after first schedule"
816 );
817
818 list.search_query.set_value("note 1");
820 list.schedule_filter(tx.clone());
821
822 assert!(
823 list.filter_task.is_some(),
824 "filter_task should still be Some after re-schedule"
825 );
826 }
827
828 #[tokio::test]
829 async fn clear_aborts_filter_task() {
830 let tx = make_tx();
831 let mut list = FileListComponent::new(
832 crate::keys::KeyBindings::empty(),
833 crate::settings::icons::Icons::new(true),
834 );
835 for i in 0..20 {
836 list.push_entry(make_note(&format!("{i}.md"), &format!("Note {i}")));
837 }
838 list.search_query.set_value("note");
839 list.schedule_filter(tx);
840
841 assert!(list.filter_task.is_some());
842 list.clear();
843 assert!(
845 list.filter_task.is_none(),
846 "filter_task should be None after clear"
847 );
848 }
849
850 fn make_note(filename: &str, title: &str) -> FileListEntry {
851 FileListEntry::Note {
852 path: VaultPath::new(filename),
853 title: title.to_string(),
854 filename: filename.to_string(),
855 journal_date: None,
856 }
857 }
858
859 fn entry_filenames(list: &FileListComponent) -> Vec<&str> {
860 list.entries
861 .iter()
862 .filter_map(|e| match e {
863 FileListEntry::Note { filename, .. } => Some(filename.as_str()),
864 _ => None,
865 })
866 .collect()
867 }
868
869 #[test]
870 fn render_accepts_focused_parameter() {
871 use crate::components::Component;
873 use ratatui::{Terminal, backend::TestBackend};
874 let backend = TestBackend::new(80, 24);
875 let mut terminal = Terminal::new(backend).unwrap();
876 let mut list = FileListComponent::new(
877 crate::keys::KeyBindings::empty(),
878 crate::settings::icons::Icons::new(true),
879 );
880 terminal
881 .draw(|f| {
882 list.render(
883 f,
884 f.area(),
885 &crate::settings::themes::Theme::default(),
886 false,
887 );
888 })
889 .unwrap();
890 }
891
892 #[test]
893 fn file_list_implements_component_trait() {
894 use crate::components::Component;
897 let mut list = FileListComponent::new(
898 crate::keys::KeyBindings::empty(),
899 crate::settings::icons::Icons::new(true),
900 );
901 let _: &mut dyn Component = &mut list;
902 }
903
904 #[test]
905 fn selected_entry_returns_highlighted_item() {
906 let mut list = FileListComponent::new(
907 crate::keys::KeyBindings::empty(),
908 crate::settings::icons::Icons::new(true),
909 );
910 list.push_entry(make_note("a.md", "A"));
911 list.push_entry(make_note("b.md", "B"));
912 let entry = list.selected_entry();
914 assert!(entry.is_some());
915 if let Some(FileListEntry::Note { filename, .. }) = entry {
916 assert_eq!(filename, "a.md");
917 } else {
918 panic!("expected Note entry");
919 }
920 }
921
922 #[test]
923 fn selected_entry_returns_none_when_empty() {
924 let list = FileListComponent::new(
925 crate::keys::KeyBindings::empty(),
926 crate::settings::icons::Icons::new(true),
927 );
928 assert!(list.selected_entry().is_none());
929 }
930
931 #[test]
932 fn prepend_create_entry_inserts_at_position_zero() {
933 let mut list = FileListComponent::new(
934 crate::keys::KeyBindings::empty(),
935 crate::settings::icons::Icons::new(true),
936 );
937 list.push_entry(make_note("a.md", "A"));
938 list.prepend_create_entry(FileListEntry::CreateNote {
939 filename: "new-note.md".to_string(),
940 path: VaultPath::new("new-note.md"),
941 });
942 assert!(matches!(
943 &list.entries[0],
944 FileListEntry::CreateNote { filename, .. } if filename == "new-note.md"
945 ));
946 }
947
948 #[test]
949 fn push_entry_does_not_sort() {
950 let mut list = FileListComponent::new(
951 crate::keys::KeyBindings::empty(),
952 crate::settings::icons::Icons::new(true),
953 );
954 list.push_entry(make_note("z.md", "Z Note"));
955 list.push_entry(make_note("a.md", "A Note"));
956 list.push_entry(make_note("m.md", "M Note"));
957 assert_eq!(entry_filenames(&list), vec!["z.md", "a.md", "m.md"]);
959 }
960
961 #[test]
962 fn finalize_sort_sorts_by_name() {
963 let mut list = FileListComponent::new(
964 crate::keys::KeyBindings::empty(),
965 crate::settings::icons::Icons::new(true),
966 );
967 list.push_entry(make_note("z.md", "Z Note"));
968 list.push_entry(make_note("a.md", "A Note"));
969 list.push_entry(make_note("m.md", "M Note"));
970 list.finalize_sort();
971 assert_eq!(entry_filenames(&list), vec!["a.md", "m.md", "z.md"]);
972 }
973
974 fn make_keybindings_with_file_ops() -> crate::keys::KeyBindings {
975 use crate::keys::key_strike::KeyStrike;
976 let mut kb = crate::keys::KeyBindings::empty();
977 kb.batch_add().add(
978 KeyStrike::F2,
979 crate::keys::action_shortcuts::ActionShortcuts::FileOperations,
980 );
981 kb
982 }
983
984 #[tokio::test]
985 async fn f2_sends_show_file_ops_menu() {
986 use crate::components::events::InputEvent;
987 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
988
989 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<AppEvent>();
990 let kb = make_keybindings_with_file_ops();
991 let mut list = FileListComponent::new(kb, crate::settings::icons::Icons::new(true));
992 list.push_entry(make_note("test.md", "Test Note"));
993
994 let key_event = KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE);
995 let input = InputEvent::Key(key_event);
996 let result = list.handle_input(&input, &tx);
997
998 assert!(
999 matches!(result, EventState::Consumed),
1000 "expected Consumed but got {:?}",
1001 result
1002 );
1003
1004 let event = rx.try_recv().expect("expected ShowFileOpsMenu to be sent");
1005 assert!(
1006 matches!(event, AppEvent::ShowFileOpsMenu(_)),
1007 "expected ShowFileOpsMenu but got {:?}",
1008 event
1009 );
1010 }
1011
1012 #[tokio::test]
1013 async fn file_ops_not_consumed_for_up_entry() {
1014 use crate::components::events::InputEvent;
1015 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1016
1017 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<AppEvent>();
1018 let kb = make_keybindings_with_file_ops();
1019 let mut list = FileListComponent::new(kb, crate::settings::icons::Icons::new(true));
1020 list.add_up_entry(VaultPath::root());
1021 let key_event = KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE);
1024 let input = InputEvent::Key(key_event);
1025 let result = list.handle_input(&input, &tx);
1026
1027 assert!(
1028 matches!(result, EventState::NotConsumed),
1029 "expected NotConsumed for Up entry but got {:?}",
1030 result
1031 );
1032 assert!(
1033 rx.try_recv().is_err(),
1034 "no event should be sent for Up entry"
1035 );
1036 }
1037
1038 #[test]
1039 fn set_create_entry_shows_at_virtual_index_zero() {
1040 let mut list = make_list();
1041 list.push_entry(make_note("a.md", "A"));
1042 list.push_entry(make_note("b.md", "B"));
1043 list.set_create_entry(Some(FileListEntry::CreateNote {
1044 filename: "new.md".to_string(),
1045 path: VaultPath::new("new.md"),
1046 }));
1047 assert_eq!(list.display_len(), 3);
1049 assert!(matches!(
1051 list.selected_entry(),
1052 Some(FileListEntry::CreateNote { filename, .. }) if filename == "new.md"
1053 ));
1054 }
1055
1056 #[test]
1057 fn set_create_entry_none_hides_it() {
1058 let mut list = make_list();
1059 list.push_entry(make_note("a.md", "A"));
1060 list.set_create_entry(Some(FileListEntry::CreateNote {
1061 filename: "new.md".to_string(),
1062 path: VaultPath::new("new.md"),
1063 }));
1064 list.set_create_entry(None);
1065 assert_eq!(list.display_len(), 1);
1066 assert!(matches!(
1067 list.selected_entry(),
1068 Some(FileListEntry::Note { .. })
1069 ));
1070 }
1071
1072 #[test]
1073 fn clear_removes_create_entry() {
1074 let mut list = make_list();
1075 list.set_create_entry(Some(FileListEntry::CreateNote {
1076 filename: "new.md".to_string(),
1077 path: VaultPath::new("new.md"),
1078 }));
1079 list.clear();
1080 assert!(list.create_entry.is_none());
1081 assert_eq!(list.display_len(), 0);
1082 }
1083
1084 #[test]
1085 fn selected_entry_with_create_entry_and_regular_note() {
1086 let mut list = make_list();
1087 list.push_entry(make_note("a.md", "A"));
1088 list.push_entry(make_note("b.md", "B"));
1089 list.set_create_entry(Some(FileListEntry::CreateNote {
1090 filename: "new.md".to_string(),
1091 path: VaultPath::new("new.md"),
1092 }));
1093 assert!(matches!(
1095 list.selected_entry(),
1096 Some(FileListEntry::CreateNote { .. })
1097 ));
1098 list.select_next();
1100 assert!(matches!(
1101 list.selected_entry(),
1102 Some(FileListEntry::Note { filename, .. }) if filename == "a.md"
1103 ));
1104 list.select_next();
1106 assert!(matches!(
1107 list.selected_entry(),
1108 Some(FileListEntry::Note { filename, .. }) if filename == "b.md"
1109 ));
1110 }
1111}