Skip to main content

kimun_notes/components/
file_list.rs

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// ---------------------------------------------------------------------------
27// Sort options
28// ---------------------------------------------------------------------------
29
30#[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// ---------------------------------------------------------------------------
93// FileListEntry
94// ---------------------------------------------------------------------------
95
96#[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    /// Sort key for the given field.
172    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    /// Terminal rows this entry occupies when rendered.
188    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// ---------------------------------------------------------------------------
251// Nucleo helper
252// ---------------------------------------------------------------------------
253
254#[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
266// ---------------------------------------------------------------------------
267// FileListComponent
268// ---------------------------------------------------------------------------
269
270pub struct FileListComponent {
271    pub entries: Vec<FileListEntry>,
272    pub loading: bool,
273    display_indices: Option<Vec<usize>>,
274    list_state: ListState,
275    // Search
276    pub search_query: SingleLineInput,
277    filter_rx: Option<Receiver<Vec<usize>>>,
278    filter_task: Option<tokio::task::JoinHandle<()>>,
279    // Sort
280    pub sort_field: SortField,
281    pub sort_order: SortOrder,
282    // Always-visible pinned entry shown above all others (used for "Create…").
283    // Not stored in `entries`; not touched by the filter.
284    create_entry: Option<FileListEntry>,
285    // Keybindings
286    key_bindings: KeyBindings,
287    // Icons resolved once at construction
288    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    /// Sort entries once after all items have been loaded.
332    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        // Reset any active filter — inserting at 0 would shift all stored indices.
343        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    /// Sort entries in-place, keeping any leading Up entry at position 0.
362    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        // Re-run filter so indices stay valid after in-place sort.
385        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    /// Number of entries currently visible in the list (respects active filter).
468    pub fn len(&self) -> usize {
469        self.display_len()
470    }
471
472    /// Number of note entries currently visible (excludes directories, Up, attachments).
473    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    /// Returns the currently selected display index (not entry index).
540    pub fn selected_display_idx(&self) -> Option<usize> {
541        self.list_state.selected()
542    }
543
544    /// Select the entry at `rel_row` (rows from top of the inner list area,
545    /// i.e. after the block border). Returns the selected display index if a
546    /// valid item was found, `None` otherwise.
547    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        // Mouse handling lives in the parent (sidebar / note browser modal),
640        // which knows the surrounding layout and can route clicks accordingly.
641        let InputEvent::Key(key) = event else {
642            return EventState::NotConsumed;
643        };
644        // Check keybindings first for action shortcuts.
645        if let Some(combo) = key_event_to_combo(key) {
646            match self.key_bindings.get_action(&combo) {
647                // FocusEditor / FocusSidebar shortcuts are intercepted
648                // at the EditorScreen level for directional navigation.
649                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        // Navigation handled directly; everything else goes to the text input.
673        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        // Drop Ctrl/Alt-modified chars so combos (e.g. Ctrl+K) don't leak as text.
685        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        // After scheduling, a task handle must be stored.
813        assert!(
814            list.filter_task.is_some(),
815            "filter_task should be Some after first schedule"
816        );
817
818        // Schedule again — the implementation must abort the old task and store a new handle.
819        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        // After clear, the handle should be gone.
844        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        // Verifies the new API: render(f, rect, theme, focused: bool) via Component trait.
872        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        // RED: fails to compile until FileListComponent implements Component.
895        // GREEN: compiles once `impl Component for FileListComponent` is added.
896        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        // Default selection is index 0
913        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        // Without sorting, entries stay in insertion order
958        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        // Up entry is selected (index 0)
1022
1023        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        // 2 notes + 1 virtual create entry
1048        assert_eq!(list.display_len(), 3);
1049        // Selection resets to 0, which is the CreateNote
1050        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        // display_idx 0 → CreateNote
1094        assert!(matches!(
1095            list.selected_entry(),
1096            Some(FileListEntry::CreateNote { .. })
1097        ));
1098        // Move selection to display_idx 1 → first Note (adjusted = 0)
1099        list.select_next();
1100        assert!(matches!(
1101            list.selected_entry(),
1102            Some(FileListEntry::Note { filename, .. }) if filename == "a.md"
1103        ));
1104        // display_idx 2 → second Note (adjusted = 1)
1105        list.select_next();
1106        assert!(matches!(
1107            list.selected_entry(),
1108            Some(FileListEntry::Note { filename, .. }) if filename == "b.md"
1109        ));
1110    }
1111}