use std::sync::mpsc::Receiver;
use kimun_core::nfs::VaultPath;
use kimun_core::{ResultType, SearchResult};
use nucleo::Matcher;
use nucleo::pattern::{CaseMatching, Normalization, Pattern};
use ratatui::Frame;
use ratatui::crossterm::event::{KeyCode, KeyModifiers};
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph};
use crate::components::Component;
use crate::components::event_state::EventState;
use crate::components::events::AppEvent;
use crate::components::events::{AppTx, InputEvent};
use crate::components::single_line_input::{InputOutcome, SingleLineInput};
use crate::keys::KeyBindings;
use crate::keys::action_shortcuts::ActionShortcuts;
use crate::keys::key_event_to_combo;
use crate::settings::icons::Icons;
use crate::settings::themes::Theme;
use crate::settings::{SortFieldSetting, SortOrderSetting};
#[derive(Clone, Copy, PartialEq)]
pub enum SortField {
Name,
Title,
}
#[derive(Clone, Copy, PartialEq)]
pub enum SortOrder {
Ascending,
Descending,
}
impl From<SortFieldSetting> for SortField {
fn from(s: SortFieldSetting) -> Self {
match s {
SortFieldSetting::Name => Self::Name,
SortFieldSetting::Title => Self::Title,
}
}
}
impl From<SortOrderSetting> for SortOrder {
fn from(s: SortOrderSetting) -> Self {
match s {
SortOrderSetting::Ascending => Self::Ascending,
SortOrderSetting::Descending => Self::Descending,
}
}
}
impl SortField {
pub fn label(self) -> char {
match self {
Self::Name => 'N',
Self::Title => 'T',
}
}
pub fn cycle(self) -> Self {
match self {
Self::Name => Self::Title,
Self::Title => Self::Name,
}
}
}
impl SortOrder {
pub fn label(self) -> char {
match self {
Self::Ascending => '↑',
Self::Descending => '↓',
}
}
pub fn toggle(self) -> Self {
match self {
Self::Ascending => Self::Descending,
Self::Descending => Self::Ascending,
}
}
}
#[derive(Clone)]
pub enum FileListEntry {
Up {
parent: VaultPath,
},
Note {
path: VaultPath,
title: String,
filename: String,
journal_date: Option<String>,
},
Directory {
path: VaultPath,
name: String,
},
Attachment {
path: VaultPath,
filename: String,
},
CreateNote {
filename: String,
path: VaultPath,
},
}
impl FileListEntry {
pub fn from_result(result: SearchResult, journal_date: Option<String>) -> Self {
let filename = result.path.get_parent_path().1;
match result.rtype {
ResultType::Note(data) => {
let title = if data.title.trim().is_empty() {
"<no title>".to_string()
} else {
data.title
};
Self::Note {
path: result.path,
title,
filename,
journal_date,
}
}
ResultType::Directory => Self::Directory {
path: result.path,
name: filename,
},
ResultType::Attachment => Self::Attachment {
path: result.path,
filename,
},
}
}
pub fn path(&self) -> &VaultPath {
match self {
Self::Up { parent } => parent,
Self::Note { path, .. } => path,
Self::Directory { path, .. } => path,
Self::Attachment { path, .. } => path,
Self::CreateNote { path, .. } => path,
}
}
pub fn search_str(&self) -> Option<String> {
match self {
Self::Up { .. } => None,
Self::Note {
title, filename, ..
} => Some(format!("{} {}", title, filename)),
Self::Directory { name, .. } => Some(name.clone()),
Self::Attachment { filename, .. } => Some(filename.clone()),
Self::CreateNote { filename, .. } => Some(filename.clone()),
}
}
fn sort_key(&self, field: SortField) -> String {
match self {
Self::Up { .. } => String::new(),
Self::Note {
title, filename, ..
} => match field {
SortField::Title => title.to_lowercase(),
SortField::Name => filename.to_lowercase(),
},
Self::Directory { name, .. } => name.to_lowercase(),
Self::Attachment { filename, .. } => filename.to_lowercase(),
Self::CreateNote { filename, .. } => filename.to_lowercase(),
}
}
pub fn visual_height(&self) -> u16 {
match self {
Self::Note { journal_date, .. } => {
if journal_date.is_some() {
3
} else {
2
}
}
_ => 1,
}
}
fn to_list_item(&self, theme: &Theme, icons: &Icons) -> ListItem<'static> {
let lines: Vec<Line> = match self {
Self::Up { .. } => vec![Line::from(Span::styled(
format!("{} [UP] ..", icons.directory_up),
Style::default().fg(theme.fg_muted.to_ratatui()),
))],
Self::Note {
title,
filename,
journal_date,
..
} => {
let mut lines = vec![];
if let Some(date) = journal_date {
lines.push(Line::from(format!("{} {}", icons.journal, title)));
lines.push(Line::from(Span::styled(
format!(" {}", date),
Style::default().fg(theme.color_journal_date.to_ratatui()),
)));
} else {
lines.push(Line::from(format!("{} {}", icons.note, title)));
}
lines.push(Line::from(Span::styled(
format!(" {}", filename),
Style::default()
.add_modifier(Modifier::ITALIC)
.fg(theme.fg_secondary.to_ratatui()),
)));
lines
}
Self::Directory { name, .. } => vec![Line::from(Span::styled(
format!("{} {}", icons.directory, name),
Style::default().fg(theme.color_directory.to_ratatui()),
))],
Self::Attachment { filename, .. } => vec![Line::from(Span::styled(
format!("{} {}", icons.attachment, filename),
Style::default()
.add_modifier(Modifier::ITALIC)
.fg(theme.fg_secondary.to_ratatui()),
))],
Self::CreateNote { filename, .. } => vec![Line::from(Span::styled(
format!("+ Create: {}", filename),
Style::default().fg(theme.accent.to_ratatui()),
))],
};
ListItem::new(Text::from(lines))
}
}
#[derive(Clone)]
struct MatchEntry {
idx: usize,
text: String,
}
impl AsRef<str> for MatchEntry {
fn as_ref(&self) -> &str {
&self.text
}
}
pub struct FileListComponent {
pub entries: Vec<FileListEntry>,
pub loading: bool,
display_indices: Option<Vec<usize>>,
list_state: ListState,
pub search_query: SingleLineInput,
filter_rx: Option<Receiver<Vec<usize>>>,
filter_task: Option<tokio::task::JoinHandle<()>>,
pub sort_field: SortField,
pub sort_order: SortOrder,
create_entry: Option<FileListEntry>,
key_bindings: KeyBindings,
icons: Icons,
}
impl FileListComponent {
pub fn new(key_bindings: KeyBindings, icons: Icons) -> Self {
Self {
entries: Vec::new(),
loading: false,
display_indices: None,
list_state: ListState::default(),
search_query: SingleLineInput::new(),
filter_rx: None,
filter_task: None,
sort_field: SortField::Name,
sort_order: SortOrder::Ascending,
create_entry: None,
key_bindings,
icons,
}
}
pub fn set_create_entry(&mut self, entry: Option<FileListEntry>) {
self.create_entry = entry;
self.reset_selection();
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn push_entry(&mut self, entry: FileListEntry) {
if matches!(
entry,
FileListEntry::Attachment { .. } | FileListEntry::CreateNote { .. }
) {
return;
}
self.entries.push(entry);
if self.display_indices.is_none() && self.list_state.selected().is_none() {
self.list_state.select(Some(0));
}
}
pub fn finalize_sort(&mut self) {
self.apply_sort();
}
pub fn add_up_entry(&mut self, parent: VaultPath) {
self.entries.insert(0, FileListEntry::Up { parent });
self.list_state.select(Some(0));
}
pub fn prepend_create_entry(&mut self, entry: FileListEntry) {
self.display_indices = None;
self.entries.insert(0, entry);
self.list_state.select(Some(0));
}
pub fn clear(&mut self) {
if let Some(handle) = self.filter_task.take() {
handle.abort();
}
self.entries.clear();
self.display_indices = None;
self.filter_rx = None;
self.search_query.clear();
self.create_entry = None;
self.list_state.select(None);
self.loading = false;
}
fn apply_sort(&mut self) {
let up_count = self
.entries
.iter()
.take_while(|e| matches!(e, FileListEntry::Up { .. }))
.count();
let field = self.sort_field;
let order = self.sort_order;
self.entries[up_count..].sort_by(|a, b| {
let ka = a.sort_key(field);
let kb = b.sort_key(field);
match order {
SortOrder::Ascending => ka.cmp(&kb),
SortOrder::Descending => kb.cmp(&ka),
}
});
}
fn set_sort(&mut self, field: SortField, order: SortOrder, tx: AppTx) {
self.sort_field = field;
self.sort_order = order;
self.apply_sort();
if !self.search_query.is_empty() {
self.schedule_filter(tx);
} else {
self.display_indices = None;
self.reset_selection();
}
}
fn schedule_filter(&mut self, tx: AppTx) {
if self.search_query.is_empty() {
self.display_indices = None;
self.filter_rx = None;
self.reset_selection();
return;
}
let candidates: Vec<MatchEntry> = self
.entries
.iter()
.enumerate()
.filter_map(|(i, e)| e.search_str().map(|text| MatchEntry { idx: i, text }))
.collect();
let query = self.search_query.value().to_string();
let (result_tx, result_rx) = std::sync::mpsc::channel();
self.filter_rx = Some(result_rx);
if let Some(handle) = self.filter_task.take() {
handle.abort();
}
let handle = tokio::spawn(async move {
let indices = tokio::task::spawn_blocking(move || {
let mut matcher = Matcher::new(nucleo::Config::DEFAULT);
let pattern = Pattern::parse(&query, CaseMatching::Ignore, Normalization::Smart);
pattern
.match_list(candidates, &mut matcher)
.into_iter()
.map(|(e, _)| e.idx)
.collect::<Vec<usize>>()
})
.await
.unwrap_or_default();
result_tx.send(indices).ok();
tx.send(AppEvent::Redraw).ok();
});
self.filter_task = Some(handle);
}
pub fn poll_filter(&mut self) {
let Some(rx) = &self.filter_rx else { return };
match rx.try_recv() {
Ok(indices) => {
let up_indices: Vec<usize> = self
.entries
.iter()
.enumerate()
.filter(|(_, e)| matches!(e, FileListEntry::Up { .. }))
.map(|(i, _)| i)
.collect();
let mut combined = up_indices;
combined.extend(indices);
self.display_indices = Some(combined);
self.filter_rx = None;
self.reset_selection();
}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
self.filter_rx = None;
}
Err(std::sync::mpsc::TryRecvError::Empty) => {}
}
}
pub fn display_len(&self) -> usize {
let base = match &self.display_indices {
None => self.entries.len(),
Some(v) => v.len(),
};
base + usize::from(self.create_entry.is_some())
}
pub fn len(&self) -> usize {
self.display_len()
}
pub fn note_count(&self) -> usize {
match &self.display_indices {
None => self
.entries
.iter()
.filter(|e| matches!(e, FileListEntry::Note { .. }))
.count(),
Some(indices) => indices
.iter()
.filter(|&&i| matches!(self.entries.get(i), Some(FileListEntry::Note { .. })))
.count(),
}
}
fn reset_selection(&mut self) {
self.list_state.select(if self.display_len() > 0 {
Some(0)
} else {
None
});
}
pub fn scroll_up(&mut self) {
let offset = self.list_state.offset();
if offset > 0 {
*self.list_state.offset_mut() = offset - 1;
if let Some(sel) = self.list_state.selected()
&& sel > 0
{
self.list_state.select(Some(sel - 1));
}
}
}
pub fn scroll_down(&mut self) {
let len = self.display_len();
let offset = self.list_state.offset();
if len > 0 && offset + 1 < len {
*self.list_state.offset_mut() = offset + 1;
if let Some(sel) = self.list_state.selected()
&& sel + 1 < len
{
self.list_state.select(Some(sel + 1));
}
}
}
pub fn select_next(&mut self) {
let len = self.display_len();
if len == 0 {
return;
}
let cur = self.list_state.selected().unwrap_or(0);
self.list_state.select(Some((cur + 1) % len));
}
pub fn select_prev(&mut self) {
let len = self.display_len();
if len == 0 {
return;
}
let cur = self.list_state.selected().unwrap_or(0);
self.list_state
.select(Some(if cur == 0 { len - 1 } else { cur - 1 }));
}
pub fn selected_display_idx(&self) -> Option<usize> {
self.list_state.selected()
}
pub fn select_at_visual_row(&mut self, rel_row: u16) -> Option<usize> {
let idx = self.display_idx_at_row(rel_row)?;
self.list_state.select(Some(idx));
Some(idx)
}
pub fn selected_entry(&self) -> Option<&FileListEntry> {
let display_idx = self.list_state.selected()?;
if self.create_entry.is_some() {
if display_idx == 0 {
return self.create_entry.as_ref();
}
let adjusted = display_idx - 1;
let entry_idx = match &self.display_indices {
None => adjusted,
Some(v) => *v.get(adjusted)?,
};
return self.entries.get(entry_idx);
}
let entry_idx = match &self.display_indices {
None => display_idx,
Some(v) => *v.get(display_idx)?,
};
self.entries.get(entry_idx)
}
pub fn activate_selected(&self, tx: &AppTx) {
let Some(display_idx) = self.list_state.selected() else {
return;
};
if self.create_entry.is_some() && display_idx == 0 {
if let Some(entry) = &self.create_entry {
tx.send(AppEvent::OpenPath(entry.path().clone())).ok();
}
return;
}
let adjusted = if self.create_entry.is_some() {
display_idx - 1
} else {
display_idx
};
let entry_idx = match &self.display_indices {
None => adjusted,
Some(v) => match v.get(adjusted) {
Some(&i) => i,
None => return,
},
};
tx.send(AppEvent::OpenPath(self.entries[entry_idx].path().clone()))
.ok();
}
fn display_idx_at_row(&self, row: u16) -> Option<usize> {
let offset = self.list_state.offset();
let len = self.display_len();
let mut y = 0u16;
for display_idx in offset..len {
let h = if self.create_entry.is_some() && display_idx == 0 {
self.create_entry
.as_ref()
.map(|e| e.visual_height())
.unwrap_or(1)
} else {
let adjusted = if self.create_entry.is_some() {
display_idx - 1
} else {
display_idx
};
let entry_idx = match &self.display_indices {
None => adjusted,
Some(v) => v.get(adjusted).copied()?,
};
self.entries
.get(entry_idx)
.map(|e| e.visual_height())
.unwrap_or(1)
};
if row < y + h {
return Some(display_idx);
}
y += h;
}
None
}
fn header_title(&self) -> String {
format!(" [{}{}]", self.sort_field.label(), self.sort_order.label())
}
}
impl Component for FileListComponent {
fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
let InputEvent::Key(key) = event else {
return EventState::NotConsumed;
};
if let Some(combo) = key_event_to_combo(key) {
match self.key_bindings.get_action(&combo) {
Some(ActionShortcuts::CycleSortField) => {
let field = self.sort_field.cycle();
self.set_sort(field, self.sort_order, tx.clone());
return EventState::Consumed;
}
Some(ActionShortcuts::SortReverseOrder) => {
let order = self.sort_order.toggle();
self.set_sort(self.sort_field, order, tx.clone());
return EventState::Consumed;
}
Some(ActionShortcuts::FileOperations) => {
if let Some(entry) = self.selected_entry()
&& !matches!(entry, FileListEntry::Up { .. })
{
tx.send(AppEvent::ShowFileOpsMenu(entry.path().clone()))
.ok();
return EventState::Consumed;
}
return EventState::NotConsumed;
}
_ => {}
}
}
match key.code {
KeyCode::Up => {
self.select_prev();
return EventState::Consumed;
}
KeyCode::Down => {
self.select_next();
return EventState::Consumed;
}
_ => {}
}
if let KeyCode::Char(_) = key.code {
let non_shift = key.modifiers - KeyModifiers::SHIFT;
if !non_shift.is_empty() {
return EventState::Consumed;
}
}
match self.search_query.handle_key(key) {
InputOutcome::Submit => {
self.activate_selected(tx);
EventState::Consumed
}
InputOutcome::Changed => {
self.schedule_filter(tx.clone());
EventState::Consumed
}
InputOutcome::Consumed | InputOutcome::Cancel => EventState::Consumed,
InputOutcome::NotConsumed => EventState::NotConsumed,
}
}
fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
self.poll_filter();
let title = self.header_title();
let bg_even = theme.bg.to_ratatui();
let bg_odd = theme.bg_panel.to_ratatui();
let entry_iter: Box<dyn Iterator<Item = &FileListEntry>> = match &self.display_indices {
None => Box::new(self.entries.iter()),
Some(indices) => Box::new(indices.iter().map(|&i| &self.entries[i])),
};
let create_iter: Box<dyn Iterator<Item = &FileListEntry>> = match &self.create_entry {
Some(e) => Box::new(std::iter::once(e)),
None => Box::new(std::iter::empty()),
};
let items: Vec<ListItem> = create_iter
.chain(entry_iter)
.enumerate()
.map(|(i, e)| {
let bg = if i % 2 == 0 { bg_even } else { bg_odd };
e.to_list_item(theme, &self.icons)
.style(Style::default().bg(bg))
})
.collect();
let border_style = theme.border_style(focused);
let make_block = || {
Block::default()
.title(title.as_str())
.borders(Borders::ALL)
.border_style(border_style)
.style(theme.panel_style())
};
let has_content = self
.entries
.iter()
.any(|e| !matches!(e, FileListEntry::Up { .. }));
if self.loading && !has_content {
let loading = Paragraph::new("Loading…")
.style(
Style::default()
.fg(theme.fg_muted.to_ratatui())
.bg(theme.bg_panel.to_ratatui()),
)
.block(make_block());
f.render_widget(loading, rect);
} else {
let list = List::new(items).block(make_block()).highlight_style(
Style::default()
.fg(theme.fg_selected.to_ratatui())
.bg(theme.bg_selected.to_ratatui()),
);
f.render_stateful_widget(list, rect, &mut self.list_state);
}
}
fn hint_shortcuts(&self) -> Vec<(String, String)> {
[
(ActionShortcuts::FocusEditor, "editor \u{2192}"),
(ActionShortcuts::CycleSortField, "cycle sort"),
(ActionShortcuts::SortReverseOrder, "reverse"),
(ActionShortcuts::FileOperations, "file ops"),
]
.iter()
.filter_map(|(action, label)| {
self.key_bindings
.first_combo_for(action)
.map(|k| (k, label.to_string()))
})
.collect()
}
}
#[cfg(test)]
mod tests {
use kimun_core::nfs::VaultPath;
use super::*;
fn make_tx() -> AppTx {
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel::<AppEvent>();
tx
}
fn make_list() -> FileListComponent {
FileListComponent::new(
crate::keys::KeyBindings::empty(),
crate::settings::icons::Icons::new(true),
)
}
#[tokio::test]
async fn schedule_filter_stores_handle_and_cancels_previous() {
let tx = make_tx();
let mut list = FileListComponent::new(
crate::keys::KeyBindings::empty(),
crate::settings::icons::Icons::new(true),
);
for i in 0..20 {
list.push_entry(make_note(&format!("{i}.md"), &format!("Note {i}")));
}
list.search_query.set_value("note");
list.schedule_filter(tx.clone());
assert!(
list.filter_task.is_some(),
"filter_task should be Some after first schedule"
);
list.search_query.set_value("note 1");
list.schedule_filter(tx.clone());
assert!(
list.filter_task.is_some(),
"filter_task should still be Some after re-schedule"
);
}
#[tokio::test]
async fn clear_aborts_filter_task() {
let tx = make_tx();
let mut list = FileListComponent::new(
crate::keys::KeyBindings::empty(),
crate::settings::icons::Icons::new(true),
);
for i in 0..20 {
list.push_entry(make_note(&format!("{i}.md"), &format!("Note {i}")));
}
list.search_query.set_value("note");
list.schedule_filter(tx);
assert!(list.filter_task.is_some());
list.clear();
assert!(
list.filter_task.is_none(),
"filter_task should be None after clear"
);
}
fn make_note(filename: &str, title: &str) -> FileListEntry {
FileListEntry::Note {
path: VaultPath::new(filename),
title: title.to_string(),
filename: filename.to_string(),
journal_date: None,
}
}
fn entry_filenames(list: &FileListComponent) -> Vec<&str> {
list.entries
.iter()
.filter_map(|e| match e {
FileListEntry::Note { filename, .. } => Some(filename.as_str()),
_ => None,
})
.collect()
}
#[test]
fn render_accepts_focused_parameter() {
use crate::components::Component;
use ratatui::{Terminal, backend::TestBackend};
let backend = TestBackend::new(80, 24);
let mut terminal = Terminal::new(backend).unwrap();
let mut list = FileListComponent::new(
crate::keys::KeyBindings::empty(),
crate::settings::icons::Icons::new(true),
);
terminal
.draw(|f| {
list.render(
f,
f.area(),
&crate::settings::themes::Theme::default(),
false,
);
})
.unwrap();
}
#[test]
fn file_list_implements_component_trait() {
use crate::components::Component;
let mut list = FileListComponent::new(
crate::keys::KeyBindings::empty(),
crate::settings::icons::Icons::new(true),
);
let _: &mut dyn Component = &mut list;
}
#[test]
fn selected_entry_returns_highlighted_item() {
let mut list = FileListComponent::new(
crate::keys::KeyBindings::empty(),
crate::settings::icons::Icons::new(true),
);
list.push_entry(make_note("a.md", "A"));
list.push_entry(make_note("b.md", "B"));
let entry = list.selected_entry();
assert!(entry.is_some());
if let Some(FileListEntry::Note { filename, .. }) = entry {
assert_eq!(filename, "a.md");
} else {
panic!("expected Note entry");
}
}
#[test]
fn selected_entry_returns_none_when_empty() {
let list = FileListComponent::new(
crate::keys::KeyBindings::empty(),
crate::settings::icons::Icons::new(true),
);
assert!(list.selected_entry().is_none());
}
#[test]
fn prepend_create_entry_inserts_at_position_zero() {
let mut list = FileListComponent::new(
crate::keys::KeyBindings::empty(),
crate::settings::icons::Icons::new(true),
);
list.push_entry(make_note("a.md", "A"));
list.prepend_create_entry(FileListEntry::CreateNote {
filename: "new-note.md".to_string(),
path: VaultPath::new("new-note.md"),
});
assert!(matches!(
&list.entries[0],
FileListEntry::CreateNote { filename, .. } if filename == "new-note.md"
));
}
#[test]
fn push_entry_does_not_sort() {
let mut list = FileListComponent::new(
crate::keys::KeyBindings::empty(),
crate::settings::icons::Icons::new(true),
);
list.push_entry(make_note("z.md", "Z Note"));
list.push_entry(make_note("a.md", "A Note"));
list.push_entry(make_note("m.md", "M Note"));
assert_eq!(entry_filenames(&list), vec!["z.md", "a.md", "m.md"]);
}
#[test]
fn finalize_sort_sorts_by_name() {
let mut list = FileListComponent::new(
crate::keys::KeyBindings::empty(),
crate::settings::icons::Icons::new(true),
);
list.push_entry(make_note("z.md", "Z Note"));
list.push_entry(make_note("a.md", "A Note"));
list.push_entry(make_note("m.md", "M Note"));
list.finalize_sort();
assert_eq!(entry_filenames(&list), vec!["a.md", "m.md", "z.md"]);
}
fn make_keybindings_with_file_ops() -> crate::keys::KeyBindings {
use crate::keys::key_strike::KeyStrike;
let mut kb = crate::keys::KeyBindings::empty();
kb.batch_add().add(
KeyStrike::F2,
crate::keys::action_shortcuts::ActionShortcuts::FileOperations,
);
kb
}
#[tokio::test]
async fn f2_sends_show_file_ops_menu() {
use crate::components::events::InputEvent;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<AppEvent>();
let kb = make_keybindings_with_file_ops();
let mut list = FileListComponent::new(kb, crate::settings::icons::Icons::new(true));
list.push_entry(make_note("test.md", "Test Note"));
let key_event = KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE);
let input = InputEvent::Key(key_event);
let result = list.handle_input(&input, &tx);
assert!(
matches!(result, EventState::Consumed),
"expected Consumed but got {:?}",
result
);
let event = rx.try_recv().expect("expected ShowFileOpsMenu to be sent");
assert!(
matches!(event, AppEvent::ShowFileOpsMenu(_)),
"expected ShowFileOpsMenu but got {:?}",
event
);
}
#[tokio::test]
async fn file_ops_not_consumed_for_up_entry() {
use crate::components::events::InputEvent;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<AppEvent>();
let kb = make_keybindings_with_file_ops();
let mut list = FileListComponent::new(kb, crate::settings::icons::Icons::new(true));
list.add_up_entry(VaultPath::root());
let key_event = KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE);
let input = InputEvent::Key(key_event);
let result = list.handle_input(&input, &tx);
assert!(
matches!(result, EventState::NotConsumed),
"expected NotConsumed for Up entry but got {:?}",
result
);
assert!(
rx.try_recv().is_err(),
"no event should be sent for Up entry"
);
}
#[test]
fn set_create_entry_shows_at_virtual_index_zero() {
let mut list = make_list();
list.push_entry(make_note("a.md", "A"));
list.push_entry(make_note("b.md", "B"));
list.set_create_entry(Some(FileListEntry::CreateNote {
filename: "new.md".to_string(),
path: VaultPath::new("new.md"),
}));
assert_eq!(list.display_len(), 3);
assert!(matches!(
list.selected_entry(),
Some(FileListEntry::CreateNote { filename, .. }) if filename == "new.md"
));
}
#[test]
fn set_create_entry_none_hides_it() {
let mut list = make_list();
list.push_entry(make_note("a.md", "A"));
list.set_create_entry(Some(FileListEntry::CreateNote {
filename: "new.md".to_string(),
path: VaultPath::new("new.md"),
}));
list.set_create_entry(None);
assert_eq!(list.display_len(), 1);
assert!(matches!(
list.selected_entry(),
Some(FileListEntry::Note { .. })
));
}
#[test]
fn clear_removes_create_entry() {
let mut list = make_list();
list.set_create_entry(Some(FileListEntry::CreateNote {
filename: "new.md".to_string(),
path: VaultPath::new("new.md"),
}));
list.clear();
assert!(list.create_entry.is_none());
assert_eq!(list.display_len(), 0);
}
#[test]
fn selected_entry_with_create_entry_and_regular_note() {
let mut list = make_list();
list.push_entry(make_note("a.md", "A"));
list.push_entry(make_note("b.md", "B"));
list.set_create_entry(Some(FileListEntry::CreateNote {
filename: "new.md".to_string(),
path: VaultPath::new("new.md"),
}));
assert!(matches!(
list.selected_entry(),
Some(FileListEntry::CreateNote { .. })
));
list.select_next();
assert!(matches!(
list.selected_entry(),
Some(FileListEntry::Note { filename, .. }) if filename == "a.md"
));
list.select_next();
assert!(matches!(
list.selected_entry(),
Some(FileListEntry::Note { filename, .. }) if filename == "b.md"
));
}
}