use std::sync::Arc;
use crate::settings::themes::Theme;
use async_trait::async_trait;
use chrono::NaiveDate;
use futures::StreamExt;
use kimun_core::nfs::VaultPath;
use kimun_core::{NoteVault, NotesValidation, ResultType, VaultBrowseOptionsBuilder};
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Position, Rect};
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use crate::components::Component;
use crate::components::event_state::EventState;
use crate::components::events::{AppEvent, AppTx, AppTxExt, FileOp, InputEvent, redraw_callback};
use crate::components::file_list::{FileListEntry, SortField, SortOrder};
use crate::components::search_list::{
Emit, Filter, KeyReaction, OrderFn, RowSource, SearchList, SearchMouse,
};
use crate::keys::KeyBindings;
use crate::settings::AppSettings;
use crate::settings::icons::Icons;
struct DirListingSource {
vault: Arc<NoteVault>,
dir: VaultPath,
}
fn listing_order(field: SortField, order: SortOrder, group_dirs: bool) -> OrderFn<FileListEntry> {
Arc::new(move |a: &FileListEntry, b: &FileListEntry| {
let rank = |e: &FileListEntry| match e {
FileListEntry::Up { .. } => 0,
FileListEntry::Directory { .. } if group_dirs => 1,
_ => 2,
};
rank(a).cmp(&rank(b)).then_with(|| {
let ka = a.sort_key(field);
let kb = b.sort_key(field);
match order {
SortOrder::Ascending => ka.cmp(&kb),
SortOrder::Descending => kb.cmp(&ka),
}
})
})
}
#[async_trait]
impl RowSource<FileListEntry> for DirListingSource {
async fn load(&self, _query: &str, emit: Emit<FileListEntry>) {
if !self.dir.is_root_or_empty() {
emit.push(FileListEntry::Up {
parent: self.dir.get_parent_path().0,
});
}
let options = VaultBrowseOptionsBuilder::new(&self.dir)
.recursive(false)
.validation(NotesValidation::Full)
.build();
let mut stream = std::pin::pin!(self.vault.browse_vault_stream(options));
while let Some(item) = stream.next().await {
let result = match item {
Ok(result) => result,
Err(e) => {
tracing::warn!("sidebar directory listing failed: {e}");
break;
}
};
if matches!(result.rtype, ResultType::Directory) && result.path.is_like(&self.dir) {
continue;
}
let journal_date = self
.vault
.journal_date(&result.path)
.map(format_journal_date);
emit.push(FileListEntry::from_result(result, journal_date));
}
emit.done();
}
fn leading_row(&self, query: &str) -> Option<FileListEntry> {
if query.is_empty() {
None
} else {
let path = self.dir.append(&VaultPath::note_path_from(query)).flatten();
Some(FileListEntry::CreateNote {
filename: path.to_string(),
path,
})
}
}
fn reload_on_query(&self) -> bool {
false
}
}
pub struct SidebarComponent {
current_dir: VaultPath,
open_note: Option<VaultPath>,
list: Option<SearchList<FileListEntry>>,
vault: Arc<NoteVault>,
icons: Icons,
default_sort_field: SortField,
default_sort_order: SortOrder,
journal_sort_field: SortField,
journal_sort_order: SortOrder,
sort: (SortField, SortOrder),
group_dirs: bool,
rendered_rect: Rect,
breadcrumb_cells: Vec<(Rect, VaultPath)>,
key_bindings: KeyBindings,
}
impl SidebarComponent {
pub fn from_settings(vault: Arc<NoteVault>, settings: &AppSettings) -> Self {
Self::new(
settings.key_bindings.clone(),
vault,
settings.icons(),
settings,
)
}
pub fn new(
key_bindings: KeyBindings,
vault: Arc<NoteVault>,
icons: Icons,
settings: &AppSettings,
) -> Self {
let default_sort_field = SortField::from(settings.default_sort_field);
let default_sort_order = SortOrder::from(settings.default_sort_order);
Self {
current_dir: VaultPath::root(),
open_note: None,
list: None,
vault,
icons,
default_sort_field,
default_sort_order,
journal_sort_field: SortField::from(settings.journal_sort_field),
journal_sort_order: SortOrder::from(settings.journal_sort_order),
sort: (default_sort_field, default_sort_order),
group_dirs: settings.group_directories,
rendered_rect: Rect::default(),
breadcrumb_cells: Vec::new(),
key_bindings,
}
}
fn breadcrumb_at(&self, column: u16, row: u16) -> Option<&VaultPath> {
self.breadcrumb_cells
.iter()
.find(|(rect, _)| rect.contains(Position { x: column, y: row }))
.map(|(_, dir)| dir)
}
pub fn current_dir(&self) -> &VaultPath {
&self.current_dir
}
pub fn is_empty(&self) -> bool {
self.list.is_none()
}
fn sort_for(&self, dir: &VaultPath) -> (SortField, SortOrder) {
if dir.is_like(self.vault.journal_path()) {
(self.journal_sort_field, self.journal_sort_order)
} else {
(self.default_sort_field, self.default_sort_order)
}
}
pub fn navigate(&mut self, dir: VaultPath, tx: &AppTx) {
self.current_dir = dir.clone();
let (sort_field, sort_order) = self.sort_for(&dir);
self.sort = (sort_field, sort_order);
let source = DirListingSource {
vault: self.vault.clone(),
dir,
};
self.list = Some(
SearchList::builder(source, redraw_callback(tx.clone()))
.filter(Filter::Fuzzy)
.order_by(listing_order(sort_field, sort_order, self.group_dirs))
.yank_combos_from(&self.key_bindings)
.icons(self.icons.clone())
.build(),
);
}
pub fn refresh_if_showing(&mut self, dir: &VaultPath, tx: &AppTx) {
if dir.is_like(&self.current_dir) {
self.navigate(self.current_dir.clone(), tx);
}
}
pub fn set_open_note(&mut self, path: Option<VaultPath>) {
self.open_note = path;
self.stamp_open_marker();
}
fn stamp_open_marker(&mut self) {
let open = self.open_note.clone();
if let Some(list) = &mut self.list {
list.update_rows(|row| {
if let FileListEntry::Note { path, is_open, .. } = row {
let want = open.as_ref().is_some_and(|o| path.is_like(o));
if *is_open != want {
*is_open = want;
return true;
}
}
false
});
}
}
pub fn update_note_row(&mut self, path: &VaultPath, new_title: &str) {
if let Some(list) = &mut self.list {
list.update_rows(|row| {
if let FileListEntry::Note {
path: row_path,
title,
..
} = row
&& row_path.is_like(path)
&& title != new_title
{
*title = new_title.to_string();
return true;
}
false
});
}
}
pub fn rename_note_row(&mut self, from: &VaultPath, to: &VaultPath) {
let new_filename = to.get_parent_path().1;
let new_journal_date = self.vault.journal_date(to).map(format_journal_date);
if let Some(list) = &mut self.list {
list.update_rows(|row| {
if let FileListEntry::Note {
path,
filename,
journal_date,
..
} = row
&& path.is_like(from)
{
*path = to.clone();
*filename = new_filename.clone();
*journal_date = new_journal_date.clone();
return true;
}
false
});
}
}
pub fn set_current_dir(&mut self, dir: VaultPath) {
self.current_dir = dir;
}
pub fn current_sort(&self) -> (SortField, SortOrder) {
self.sort
}
pub fn group_dirs(&self) -> bool {
self.group_dirs
}
pub fn apply_sort(&mut self, field: SortField, order: SortOrder, group_dirs: bool) {
self.sort = (field, order);
self.group_dirs = group_dirs;
if let Some(list) = &mut self.list {
list.set_order(Some(listing_order(field, order, group_dirs)));
}
}
pub fn is_current_journal(&self) -> bool {
self.current_dir.is_like(self.vault.journal_path())
}
pub fn save_default(&mut self, field: SortField, order: SortOrder, group_dirs: bool) {
if self.is_current_journal() {
self.journal_sort_field = field;
self.journal_sort_order = order;
} else {
self.default_sort_field = field;
self.default_sort_order = order;
}
self.apply_sort(field, order, group_dirs);
}
fn note_count(&self) -> usize {
match &self.list {
None => 0,
Some(list) => list
.visible_rows()
.iter()
.filter(|e| matches!(e, FileListEntry::Note { .. }))
.count(),
}
}
fn activate_selected_entry(&self, tx: &AppTx) {
let Some(list) = &self.list else { return };
let Some(entry) = list.selected_row() else {
return;
};
match entry {
FileListEntry::CreateNote { path, .. } => {
let path = path.clone();
let vault = Arc::clone(&self.vault);
let tx2 = tx.clone();
tokio::spawn(async move {
match vault.load_or_create_note(&path, None).await {
Ok((_, created)) => tx2.announce_and_open(path, created),
Err(e) => {
tracing::warn!("create note failed for {path}: {e}");
}
}
});
}
FileListEntry::Attachment { path, .. } => {
tx.send(AppEvent::OpenAttachment(path.clone())).ok();
}
other => {
tx.send(AppEvent::open(other.path().clone())).ok();
}
}
}
}
fn format_journal_date(date: NaiveDate) -> String {
date.format("%A, %B %-d, %Y").to_string()
}
impl Component for SidebarComponent {
fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
if let InputEvent::Mouse(mouse) = event {
let pos = Position {
x: mouse.column,
y: mouse.row,
};
if !self.rendered_rect.contains(pos) {
return EventState::NotConsumed;
}
if matches!(
mouse.kind,
ratatui::crossterm::event::MouseEventKind::Down(
ratatui::crossterm::event::MouseButton::Left
)
) && let Some(dir) = self.breadcrumb_at(mouse.column, mouse.row)
{
tx.send(AppEvent::open(dir.clone())).ok();
return EventState::Consumed;
}
if let Some(list) = &mut self.list {
match list.handle_mouse(mouse) {
SearchMouse::Activated(_) => self.activate_selected_entry(tx),
SearchMouse::Context(_) => {
if let Some(entry) = list.selected_row()
&& !matches!(
entry,
FileListEntry::Up { .. } | FileListEntry::CreateNote { .. }
)
{
tx.send(AppEvent::FileOp(FileOp::ShowMenu(entry.path().clone())))
.ok();
}
}
SearchMouse::Selected(_)
| SearchMouse::Scrolled
| SearchMouse::ContentScrollUp
| SearchMouse::ContentScrollDown
| SearchMouse::None => {}
}
}
return EventState::Consumed;
}
if let InputEvent::Key(key) = event {
if self.list.is_none() {
return EventState::NotConsumed;
}
let reaction = self.list.as_mut().unwrap().handle_key(key);
match reaction {
KeyReaction::Submit => {
self.activate_selected_entry(tx);
EventState::Consumed
}
KeyReaction::Consumed | KeyReaction::Cancel => EventState::Consumed,
KeyReaction::Yank(target) => {
crate::components::yank_row(target, tx);
EventState::Consumed
}
KeyReaction::Intercepted(_) | KeyReaction::ListVerb(_) | KeyReaction::Unhandled => {
EventState::NotConsumed
}
}
} else {
EventState::NotConsumed
}
}
fn hint_shortcuts(&self) -> Vec<(String, String)> {
use crate::keys::action_shortcuts::ActionShortcuts;
crate::components::hints::hints_for(
&self.key_bindings,
&[
(ActionShortcuts::FocusSidebar, "\u{2190} focus left"),
(ActionShortcuts::FocusEditor, "focus right \u{2192}"),
(ActionShortcuts::OpenSortDialog, "sort"),
],
)
}
fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
self.rendered_rect = rect;
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Length(3),
Constraint::Min(0),
])
.split(rect);
let border_style = theme.border_style(focused);
let header = Block::default()
.title(format!("─ Files · {} ", self.current_dir))
.borders(Borders::ALL)
.border_style(border_style)
.style(theme.panel_style());
let header_inner = header.inner(rows[0]);
f.render_widget(header, rows[0]);
self.breadcrumb_cells.clear();
let seg_style = Style::default()
.fg(theme.fg_secondary.to_ratatui())
.bg(theme.bg_panel.to_ratatui());
let sep_style = Style::default()
.fg(theme.gray.to_ratatui())
.bg(theme.bg_panel.to_ratatui());
let mut spans: Vec<Span> = Vec::new();
let mut x = header_inner.x;
let mut push_segment =
|spans: &mut Vec<Span>, x: &mut u16, label: String, dir: VaultPath| {
let w = unicode_width::UnicodeWidthStr::width(label.as_str()) as u16;
if *x < header_inner.right() {
let visible = w.min(header_inner.right() - *x);
self.breadcrumb_cells
.push((Rect::new(*x, header_inner.y, visible, 1), dir));
}
spans.push(Span::styled(label, seg_style));
*x += w;
};
push_segment(&mut spans, &mut x, "~".to_string(), VaultPath::root());
let slices = self.current_dir.get_slices();
let mut acc = String::new();
for slice in &slices {
spans.push(Span::styled(" / ", sep_style));
x += 3;
acc.push('/');
acc.push_str(slice);
push_segment(&mut spans, &mut x, slice.clone(), VaultPath::new(&acc));
}
let count = format!("{} notes", self.note_count());
let used: u16 = x - header_inner.x;
let pad = header_inner
.width
.saturating_sub(used)
.saturating_sub(unicode_width::UnicodeWidthStr::width(count.as_str()) as u16);
spans.push(Span::styled(" ".repeat(pad as usize), sep_style));
spans.push(Span::styled(count, sep_style));
f.render_widget(Paragraph::new(Line::from(spans)), header_inner);
let search_block = Block::default()
.title(" Search")
.borders(Borders::ALL)
.border_style(border_style)
.style(theme.panel_style());
let search_inner = search_block.inner(rows[1]);
f.render_widget(search_block, rows[1]);
let list_block = Block::default()
.borders(Borders::ALL)
.border_style(border_style)
.style(theme.panel_style());
let list_inner = list_block.inner(rows[2]);
f.render_widget(list_block, rows[2]);
if let Some(list) = &mut self.list {
list.poll();
}
self.stamp_open_marker();
if let Some(list) = &mut self.list {
list.render_query(f, search_inner, theme, focused);
list.render(f, list_inner, theme, focused);
list.set_list_rect(list_inner);
list.set_panel_rect(rect);
}
}
}
#[cfg(test)]
impl SidebarComponent {
pub(crate) fn poll_for_test(&mut self) {
if let Some(list) = &mut self.list {
list.poll();
}
self.stamp_open_marker();
}
pub(crate) fn is_loading_for_test(&self) -> bool {
self.list.as_ref().is_some_and(|l| l.is_loading())
}
pub(crate) fn note_row_is_open_for_test(&self, name: &str) -> bool {
self.list.as_ref().is_some_and(|l| {
l.rows().iter().any(|r| {
matches!(r, FileListEntry::Note { path, is_open, .. }
if path.get_name() == name && *is_open)
})
})
}
pub(crate) fn note_row_title_for_test(&self, name: &str) -> Option<String> {
self.list.as_ref().and_then(|l| {
l.rows().iter().find_map(|r| match r {
FileListEntry::Note { path, title, .. } if path.get_name() == name => {
Some(title.clone())
}
_ => None,
})
})
}
pub(crate) fn note_row_journal_date_for_test(&self, path: &VaultPath) -> Option<String> {
self.list.as_ref().and_then(|l| {
l.rows().iter().find_map(|r| match r {
FileListEntry::Note {
path: row_path,
journal_date,
..
} if row_path.is_like(path) => journal_date.clone(),
_ => None,
})
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::settings::AppSettings;
use crate::test_support::{mouse_down_at, temp_vault};
use ratatui::crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
use tokio::sync::mpsc::unbounded_channel;
async fn make_sidebar() -> SidebarComponent {
let vault = temp_vault("sidebar").await;
vault.validate_and_init().await.unwrap();
let settings = AppSettings::default();
SidebarComponent::new(
settings.key_bindings.clone(),
vault,
settings.icons(),
&settings,
)
}
async fn sidebar_with_notes(prefix: &str, names: &[&str]) -> SidebarComponent {
let vault = temp_vault(prefix).await;
vault.validate_and_init().await.unwrap();
for name in names {
vault
.create_note(&VaultPath::note_path_from(name), "body")
.await
.unwrap();
}
let settings = AppSettings::default();
SidebarComponent::new(
settings.key_bindings.clone(),
vault,
settings.icons(),
&settings,
)
}
#[tokio::test]
async fn mouse_down_in_sidebar_bounds_is_consumed() {
let mut sidebar = make_sidebar().await;
sidebar.rendered_rect = Rect {
x: 0,
y: 3,
width: 30,
height: 20,
};
let (tx, _rx) = unbounded_channel();
assert_eq!(
sidebar.handle_input(&mouse_down_at(5, 4), &tx),
EventState::Consumed
);
assert_eq!(
sidebar.handle_input(&mouse_down_at(5, 7), &tx),
EventState::Consumed
);
assert_eq!(
sidebar.handle_input(&mouse_down_at(40, 7), &tx),
EventState::NotConsumed
);
}
fn scroll_event_at(col: u16, row: u16, kind: MouseEventKind) -> InputEvent {
InputEvent::Mouse(MouseEvent {
kind,
column: col,
row,
modifiers: KeyModifiers::NONE,
})
}
async fn navigate_to_root(sidebar: &mut SidebarComponent, tx: &AppTx) {
sidebar.navigate(VaultPath::root(), tx);
for _ in 0..50 {
if let Some(list) = &mut sidebar.list {
list.poll();
if !list.is_loading() {
break;
}
}
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
if let Some(list) = &mut sidebar.list {
list.poll();
}
}
#[tokio::test(flavor = "multi_thread")]
async fn mouse_double_click_on_list_row_sends_open_path() {
let mut sidebar = sidebar_with_notes("sidebar-dbl", &["alpha"]).await;
let (tx, mut rx) = unbounded_channel();
navigate_to_root(&mut sidebar, &tx).await;
sidebar.rendered_rect = Rect {
x: 0,
y: 3,
width: 30,
height: 20,
};
if let Some(list) = &mut sidebar.list {
list.set_list_rect(Rect {
x: 0,
y: 9,
width: 30,
height: 14,
});
}
sidebar.handle_input(&mouse_down_at(5, 9), &tx);
sidebar.handle_input(&mouse_down_at(5, 9), &tx);
let mut events = Vec::new();
while let Ok(evt) = rx.try_recv() {
events.push(evt);
}
assert!(
events
.iter()
.any(|e| matches!(e, AppEvent::OpenPath { path: p, .. } if p.to_string().contains("alpha"))),
"expected OpenPath for the activated note, got {events:?}"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn scroll_down_in_sidebar_bounds_scrolls_list() {
let mut sidebar = sidebar_with_notes("sidebar-scroll", &["alpha", "beta"]).await;
let (tx, _rx) = unbounded_channel();
navigate_to_root(&mut sidebar, &tx).await;
sidebar.rendered_rect = Rect {
x: 0,
y: 3,
width: 30,
height: 20,
};
if let Some(list) = &mut sidebar.list {
list.set_list_rect(Rect {
x: 0,
y: 9,
width: 30,
height: 1,
});
list.set_panel_rect(Rect {
x: 0,
y: 3,
width: 30,
height: 20,
});
}
let first = sidebar
.list
.as_ref()
.unwrap()
.selected_row()
.map(|e| e.path().to_string());
let result = sidebar.handle_input(&scroll_event_at(5, 4, MouseEventKind::ScrollDown), &tx);
assert_eq!(result, EventState::Consumed);
let after = sidebar
.list
.as_ref()
.unwrap()
.selected_row()
.map(|e| e.path().to_string());
assert_ne!(
first, after,
"scroll-from-header should scroll the list, carrying the selection"
);
}
#[tokio::test]
async fn mouse_down_outside_sidebar_is_not_consumed() {
let mut sidebar = make_sidebar().await;
sidebar.rendered_rect = Rect {
x: 0,
y: 3,
width: 30,
height: 20,
};
let (tx, mut rx) = unbounded_channel();
let result = sidebar.handle_input(&mouse_down_at(50, 10), &tx);
assert_eq!(result, EventState::NotConsumed);
assert!(rx.try_recv().is_err());
}
#[tokio::test(flavor = "multi_thread")]
async fn navigate_loads_directory_notes() {
let mut sidebar = sidebar_with_notes("sidebar-nav", &["hello"]).await;
assert!(sidebar.is_empty());
let (tx, _rx) = unbounded_channel();
navigate_to_root(&mut sidebar, &tx).await;
assert!(!sidebar.is_empty());
assert_eq!(sidebar.note_count(), 1);
}
async fn poll_to_idle(sidebar: &mut SidebarComponent) {
for _ in 0..50 {
if let Some(list) = &mut sidebar.list {
list.poll();
if !list.is_loading() {
break;
}
}
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
if let Some(list) = &mut sidebar.list {
list.poll();
}
}
fn note_names(sidebar: &SidebarComponent) -> Vec<String> {
sidebar
.list
.as_ref()
.unwrap()
.visible_rows()
.iter()
.filter_map(|e| match e {
FileListEntry::Note { filename, .. } => Some(filename.clone()),
_ => None,
})
.collect()
}
#[tokio::test(flavor = "multi_thread")]
async fn listing_streams_each_row_then_done() {
use crate::components::search_list::Loaded;
let sidebar = sidebar_with_notes("sidebar-stream", &["charlie", "alpha", "bravo"]).await;
let source = DirListingSource {
vault: sidebar.vault.clone(),
dir: VaultPath::root(),
};
let (emit, rx) = Emit::capture();
source.load("", emit).await;
let events: Vec<Loaded<FileListEntry>> = rx.try_iter().map(|(_, ev)| ev).collect();
let pushed_notes = events
.iter()
.filter(|e| matches!(e, Loaded::Push(FileListEntry::Note { .. })))
.count();
assert_eq!(pushed_notes, 3, "each note is streamed as its own row");
assert!(
!events.iter().any(|e| matches!(e, Loaded::Replace(_))),
"no whole-set re-delivery"
);
assert!(
matches!(events.last(), Some(Loaded::Done)),
"done closes the load"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn apply_sort_reorders_without_reloading() {
let mut sidebar =
sidebar_with_notes("sidebar-resort", &["alpha", "bravo", "charlie"]).await;
let (tx, _rx) = unbounded_channel();
navigate_to_root(&mut sidebar, &tx).await;
let before = note_names(&sidebar);
assert_eq!(before.len(), 3, "expected three notes, got {before:?}");
sidebar.apply_sort(SortField::Name, SortOrder::Descending, false);
let list = sidebar.list.as_ref().unwrap();
assert!(
!list.is_loading(),
"a sort change must not reload the listing"
);
assert_eq!(
note_names(&sidebar),
before.iter().rev().cloned().collect::<Vec<_>>(),
"rows re-ordered in place"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn apply_sort_reverse_flips_listing_order() {
let mut sidebar = sidebar_with_notes("sidebar-sort", &["alpha", "bravo", "charlie"]).await;
let (tx, _rx) = unbounded_channel();
navigate_to_root(&mut sidebar, &tx).await;
let before = note_names(&sidebar);
assert_eq!(before.len(), 3, "expected three notes, got {before:?}");
sidebar.apply_sort(SortField::Name, SortOrder::Descending, false);
poll_to_idle(&mut sidebar).await;
let after = note_names(&sidebar);
assert_eq!(
after,
before.iter().rev().cloned().collect::<Vec<_>>(),
"descending order should reverse the listing"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn apply_sort_changes_field() {
let mut sidebar = sidebar_with_notes("sidebar-cycle", &["alpha", "bravo"]).await;
let (tx, _rx) = unbounded_channel();
navigate_to_root(&mut sidebar, &tx).await;
sidebar.apply_sort(SortField::Title, SortOrder::Ascending, false);
poll_to_idle(&mut sidebar).await;
assert_eq!(sidebar.current_sort().0, SortField::Title);
assert_eq!(note_names(&sidebar).len(), 2, "notes survive the resort");
}
async fn sidebar_with_notes_and_dir(prefix: &str) -> SidebarComponent {
let vault = temp_vault(prefix).await;
vault.validate_and_init().await.unwrap();
vault
.create_note(&VaultPath::note_path_from("alpha"), "body")
.await
.unwrap();
vault
.create_note(&VaultPath::note_path_from("z-dir/inner"), "body")
.await
.unwrap();
let settings = AppSettings::default();
SidebarComponent::new(
settings.key_bindings.clone(),
vault,
settings.icons(),
&settings,
)
}
fn row_kinds(sidebar: &SidebarComponent) -> Vec<&'static str> {
sidebar
.list
.as_ref()
.unwrap()
.visible_rows()
.iter()
.filter_map(|e| match e {
FileListEntry::Note { .. } => Some("note"),
FileListEntry::Directory { .. } => Some("dir"),
_ => None,
})
.collect()
}
#[tokio::test(flavor = "multi_thread")]
async fn group_dirs_puts_directories_first() {
let mut sidebar = sidebar_with_notes_and_dir("sidebar-group").await;
let (tx, _rx) = unbounded_channel();
navigate_to_root(&mut sidebar, &tx).await;
assert_eq!(row_kinds(&sidebar), vec!["note", "dir"]);
sidebar.apply_sort(SortField::Name, SortOrder::Ascending, true);
poll_to_idle(&mut sidebar).await;
assert_eq!(
row_kinds(&sidebar),
vec!["dir", "note"],
"grouping must cluster directories first"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn apply_sort_updates_shared_state() {
let mut sidebar = sidebar_with_notes("sidebar-apply", &["alpha", "bravo"]).await;
let (tx, _rx) = unbounded_channel();
navigate_to_root(&mut sidebar, &tx).await;
sidebar.apply_sort(SortField::Title, SortOrder::Descending, false);
poll_to_idle(&mut sidebar).await;
assert_eq!(
sidebar.current_sort(),
(SortField::Title, SortOrder::Descending)
);
assert!(!sidebar.group_dirs());
}
#[tokio::test(flavor = "multi_thread")]
async fn set_open_note_stamps_matching_row() {
let mut sb = sidebar_with_notes("sb-open", &["alpha", "beta"]).await;
let (tx, _rx) = unbounded_channel();
navigate_to_root(&mut sb, &tx).await;
sb.set_open_note(Some(VaultPath::note_path_from("alpha")));
assert!(
sb.note_row_is_open_for_test("alpha.md"),
"open note is marked"
);
assert!(
!sb.note_row_is_open_for_test("beta.md"),
"other note is not marked"
);
sb.set_open_note(Some(VaultPath::note_path_from("beta")));
assert!(!sb.note_row_is_open_for_test("alpha.md"));
assert!(sb.note_row_is_open_for_test("beta.md"));
sb.set_open_note(None);
assert!(!sb.note_row_is_open_for_test("beta.md"));
}
#[tokio::test(flavor = "multi_thread")]
async fn update_note_row_changes_title_in_place() {
let mut sb = sidebar_with_notes("sb-title", &["alpha"]).await;
let (tx, _rx) = unbounded_channel();
navigate_to_root(&mut sb, &tx).await;
sb.update_note_row(&VaultPath::note_path_from("alpha"), "Fresh Title");
assert_eq!(
sb.note_row_title_for_test("alpha.md").as_deref(),
Some("Fresh Title")
);
}
#[tokio::test(flavor = "multi_thread")]
async fn rename_note_row_updates_path_and_filename() {
let mut sb = sidebar_with_notes("sb-rename", &["alpha"]).await;
let (tx, _rx) = unbounded_channel();
navigate_to_root(&mut sb, &tx).await;
let to = VaultPath::note_path_from("gamma");
let expected_filename = to.get_parent_path().1;
sb.rename_note_row(&VaultPath::note_path_from("alpha"), &to);
assert!(
sb.note_row_title_for_test("gamma.md").is_some(),
"row now at new name"
);
assert!(
sb.note_row_title_for_test("alpha.md").is_none(),
"old name gone"
);
let renamed_filename = sb
.list
.as_ref()
.unwrap()
.rows()
.iter()
.find_map(|r| match r {
FileListEntry::Note { path, filename, .. } if path.is_like(&to) => {
Some(filename.clone())
}
_ => None,
});
assert_eq!(
renamed_filename.as_deref(),
Some(expected_filename.as_str()),
"filename field must be updated to the new name"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn rename_note_row_clears_journal_date_when_renamed_away_from_date_name() {
let vault = crate::test_support::temp_vault("sb-jdate").await;
vault.validate_and_init().await.unwrap();
let journal_path = vault.journal_path().clone();
let date_name = "2026-06-09";
let from = journal_path
.append(&VaultPath::note_path_from(date_name))
.absolute();
vault.create_note(&from, "journal body").await.unwrap();
let settings = AppSettings::default();
let mut sb = SidebarComponent::new(
settings.key_bindings.clone(),
vault,
settings.icons(),
&settings,
);
let (tx, _rx) = unbounded_channel();
sb.navigate(journal_path.clone(), &tx);
for _ in 0..50 {
sb.poll_for_test();
if !sb.is_loading_for_test() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
}
sb.poll_for_test();
assert!(
sb.note_row_journal_date_for_test(&from).is_some(),
"journal note must have a journal_date before rename"
);
let to = journal_path
.append(&VaultPath::note_path_from("meeting"))
.absolute();
sb.rename_note_row(&from, &to);
assert_eq!(
sb.note_row_journal_date_for_test(&to),
None,
"journal_date must be cleared after renaming to a non-date name"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn save_default_survives_navigation() {
let mut sidebar = sidebar_with_notes("sidebar-savedef", &["alpha", "bravo"]).await;
let (tx, _rx) = unbounded_channel();
navigate_to_root(&mut sidebar, &tx).await;
sidebar.save_default(SortField::Title, SortOrder::Descending, false);
poll_to_idle(&mut sidebar).await;
sidebar.navigate(VaultPath::root(), &tx);
poll_to_idle(&mut sidebar).await;
assert_eq!(
sidebar.current_sort(),
(SortField::Title, SortOrder::Descending),
"saved default must persist across navigation"
);
}
struct SlowListingSource {
slot: Arc<std::sync::Mutex<Option<Emit<FileListEntry>>>>,
}
#[async_trait]
impl RowSource<FileListEntry> for SlowListingSource {
async fn load(&self, _query: &str, emit: Emit<FileListEntry>) {
*self.slot.lock().unwrap() = Some(emit);
}
fn reload_on_query(&self) -> bool {
false
}
}
async fn slow_listing(
field: SortField,
order: SortOrder,
group_dirs: bool,
) -> (SearchList<FileListEntry>, Emit<FileListEntry>) {
let slot = Arc::new(std::sync::Mutex::new(None));
let list = SearchList::builder(
SlowListingSource { slot: slot.clone() },
Arc::new(|| {}) as Arc<dyn Fn() + Send + Sync>,
)
.filter(Filter::Fuzzy)
.order_by(listing_order(field, order, group_dirs))
.build();
let emit = loop {
if let Some(e) = slot.lock().unwrap().clone() {
break e;
}
tokio::task::yield_now().await;
};
(list, emit)
}
fn note_row(title: &str) -> FileListEntry {
let path = VaultPath::note_path_from(title);
let filename = path.get_parent_path().1;
FileListEntry::Note {
path,
title: title.to_string(),
filename,
journal_date: None,
is_open: false,
}
}
fn dir_row(name: &str) -> FileListEntry {
FileListEntry::Directory {
path: VaultPath::new(name),
name: name.to_string(),
}
}
fn labels(list: &SearchList<FileListEntry>) -> Vec<String> {
list.visible_rows().iter().map(|r| label(r)).collect()
}
fn label(row: &FileListEntry) -> String {
match row {
FileListEntry::Up { .. } => "..".to_string(),
FileListEntry::Directory { name, .. } => format!("{name}/"),
FileListEntry::Note { title, .. } => title.clone(),
FileListEntry::Attachment { filename, .. } => filename.clone(),
FileListEntry::CreateNote { filename, .. } => format!("create:{filename}"),
}
}
fn highlighted(list: &SearchList<FileListEntry>) -> Option<String> {
list.selected_row().map(label)
}
#[tokio::test]
async fn a_slow_listing_keeps_the_untouched_highlight_on_the_top_row() {
let (mut list, emit) = slow_listing(SortField::Name, SortOrder::Ascending, false).await;
emit.push(note_row("mike"));
list.poll();
assert_eq!(highlighted(&list).as_deref(), Some("mike"));
emit.push(note_row("delta"));
list.poll();
assert_eq!(labels(&list), ["delta", "mike"]);
assert_eq!(highlighted(&list).as_deref(), Some("delta"));
emit.push(note_row("alpha"));
list.poll();
assert_eq!(labels(&list), ["alpha", "delta", "mike"]);
assert_eq!(
highlighted(&list).as_deref(),
Some("alpha"),
"the highlight follows the top of the sort, not the first row to arrive"
);
emit.done();
}
#[tokio::test]
async fn a_slow_subdirectory_listing_keeps_the_highlight_on_the_up_row() {
let (mut list, emit) = slow_listing(SortField::Name, SortOrder::Ascending, false).await;
emit.push(FileListEntry::Up {
parent: VaultPath::root(),
});
list.poll();
assert_eq!(highlighted(&list).as_deref(), Some(".."));
emit.push(note_row("alpha"));
list.poll();
emit.push(note_row("aaron")); list.poll();
assert_eq!(labels(&list), ["..", "aaron", "alpha"]);
assert_eq!(
highlighted(&list).as_deref(),
Some(".."),
"`Up` outranks every arriving entry, so the seed never leaves it"
);
emit.done();
}
#[tokio::test]
async fn a_sort_change_mid_stream_keeps_the_untouched_highlight_on_top() {
let (mut list, emit) = slow_listing(SortField::Name, SortOrder::Ascending, false).await;
emit.push(note_row("bravo"));
emit.push(note_row("delta"));
list.poll();
assert_eq!(labels(&list), ["bravo", "delta"]);
assert_eq!(highlighted(&list).as_deref(), Some("bravo"));
list.set_order(Some(listing_order(
SortField::Name,
SortOrder::Descending,
false,
)));
assert_eq!(labels(&list), ["delta", "bravo"]);
assert_eq!(
highlighted(&list).as_deref(),
Some("delta"),
"a re-sort moves the untouched highlight to the new top row"
);
emit.push(note_row("echo"));
list.poll();
assert_eq!(labels(&list), ["echo", "delta", "bravo"]);
assert_eq!(
highlighted(&list).as_deref(),
Some("echo"),
"the sort change must not turn the seed into a user choice"
);
emit.done();
}
#[tokio::test]
async fn a_row_chosen_mid_stream_survives_a_sort_change_and_later_arrivals() {
let (mut list, emit) = slow_listing(SortField::Name, SortOrder::Ascending, false).await;
emit.push(note_row("bravo"));
emit.push(note_row("delta"));
list.poll();
list.select_next(); assert_eq!(highlighted(&list).as_deref(), Some("delta"));
list.set_order(Some(listing_order(
SortField::Name,
SortOrder::Descending,
false,
)));
assert_eq!(labels(&list), ["delta", "bravo"]);
assert_eq!(highlighted(&list).as_deref(), Some("delta"));
emit.push(note_row("echo")); list.poll();
assert_eq!(labels(&list), ["echo", "delta", "bravo"]);
assert_eq!(
highlighted(&list).as_deref(),
Some("delta"),
"a chosen row keeps the highlight through a re-sort and later arrivals"
);
emit.done();
}
#[tokio::test]
async fn a_late_directory_takes_the_top_when_directories_are_grouped() {
let (mut list, emit) = slow_listing(SortField::Name, SortOrder::Ascending, true).await;
emit.push(note_row("alpha"));
emit.push(note_row("bravo"));
list.poll();
assert_eq!(highlighted(&list).as_deref(), Some("alpha"));
emit.push(dir_row("zeta"));
list.poll();
assert_eq!(labels(&list), ["zeta/", "alpha", "bravo"]);
assert_eq!(
highlighted(&list).as_deref(),
Some("zeta/"),
"grouped directories outrank notes whenever they arrive"
);
emit.done();
}
#[tokio::test]
async fn filtering_mid_stream_keeps_a_chosen_row_while_more_rows_arrive() {
let (mut list, emit) = slow_listing(SortField::Name, SortOrder::Ascending, false).await;
emit.push(note_row("alpha"));
emit.push(note_row("albatross"));
emit.push(note_row("zebra"));
list.poll();
list.set_query("al");
assert_eq!(labels(&list), ["albatross", "alpha"]);
list.select_next(); assert_eq!(highlighted(&list).as_deref(), Some("alpha"));
emit.push(note_row("alabama"));
emit.push(note_row("yankee"));
list.poll();
assert_eq!(labels(&list), ["alabama", "albatross", "alpha"]);
assert_eq!(
highlighted(&list).as_deref(),
Some("alpha"),
"the chosen row keeps the highlight behind an active filter"
);
list.set_query("");
assert_eq!(
labels(&list),
["alabama", "albatross", "alpha", "yankee", "zebra"]
);
assert_eq!(highlighted(&list).as_deref(), Some("alpha"));
emit.done();
}
#[tokio::test]
async fn an_in_place_update_that_re_sorts_carries_the_highlight_with_the_row() {
let (mut list, emit) = slow_listing(SortField::Title, SortOrder::Ascending, false).await;
emit.push(note_row("alpha"));
emit.push(note_row("bravo"));
emit.push(note_row("charlie"));
list.poll();
list.select_next();
list.select_next();
assert_eq!(highlighted(&list).as_deref(), Some("charlie"));
list.update_rows(|row| {
if let FileListEntry::Note { title, .. } = row
&& title == "charlie"
{
*title = "aaa".to_string();
return true;
}
false
});
assert_eq!(labels(&list), ["aaa", "alpha", "bravo"]);
assert_eq!(
highlighted(&list).as_deref(),
Some("aaa"),
"the highlight belongs to the row, not to the position it used to hold"
);
emit.done();
}
#[tokio::test]
async fn an_in_place_update_that_re_sorts_leaves_a_seed_on_the_top_row() {
let (mut list, emit) = slow_listing(SortField::Title, SortOrder::Ascending, false).await;
emit.push(note_row("bravo"));
emit.push(note_row("charlie"));
list.poll();
assert_eq!(highlighted(&list).as_deref(), Some("bravo"));
list.update_rows(|row| {
if let FileListEntry::Note { title, .. } = row
&& title == "charlie"
{
*title = "aaa".to_string();
return true;
}
false
});
assert_eq!(labels(&list), ["aaa", "bravo"]);
assert_eq!(
highlighted(&list).as_deref(),
Some("aaa"),
"an untouched highlight belongs to the top of the order after any recompute"
);
emit.done();
}
}