use std::sync::{Arc, Mutex};
use crate::settings::themes::Theme;
use async_trait::async_trait;
use chrono::NaiveDate;
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, InputEvent, redraw_callback};
use crate::components::file_list::{FileListEntry, SortField, SortOrder};
use crate::components::search_list::{
Emit, Filter, KeyReaction, RowSource, SearchList, SearchMouse,
};
use crate::keys::KeyBindings;
use crate::settings::AppSettings;
use crate::settings::icons::Icons;
struct DirListingSource {
vault: Arc<NoteVault>,
dir: VaultPath,
sort: Arc<Mutex<(SortField, SortOrder)>>,
group_dirs: Arc<Mutex<bool>>,
}
#[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, rx) = VaultBrowseOptionsBuilder::new(&self.dir)
.recursive(false)
.validation(NotesValidation::Full)
.build();
let vault = self.vault.clone();
let browse = tokio::spawn(async move { vault.browse_vault(options).await });
let vault = self.vault.clone();
let dir = self.dir.clone();
let (field, order) = *self.sort.lock().unwrap();
let group_dirs = *self.group_dirs.lock().unwrap();
let drain = tokio::task::spawn_blocking(move || {
let mut entries: Vec<FileListEntry> = Vec::new();
while let Ok(result) = rx.recv() {
if matches!(result.rtype, ResultType::Directory) && result.path == dir {
continue;
}
let journal_date = vault.journal_date(&result.path).map(format_journal_date);
entries.push(FileListEntry::from_result(result, journal_date));
}
let cmp = |a: &FileListEntry, b: &FileListEntry| {
let ka = a.sort_key(field);
let kb = b.sort_key(field);
match order {
SortOrder::Ascending => ka.cmp(&kb),
SortOrder::Descending => kb.cmp(&ka),
}
};
if group_dirs {
let (mut dirs, mut rest): (Vec<_>, Vec<_>) = entries
.into_iter()
.partition(|e| matches!(e, FileListEntry::Directory { .. }));
dirs.sort_by(&cmp);
rest.sort_by(&cmp);
dirs.extend(rest);
dirs
} else {
entries.sort_by(&cmp);
entries
}
});
match drain.await {
Ok(entries) => {
for entry in entries {
emit.push(entry);
}
}
Err(e) => tracing::warn!("sidebar directory listing drain failed: {e}"),
}
if let Err(e) = browse.await {
tracing::warn!("sidebar browse_vault task failed: {e}");
}
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: Arc<Mutex<(SortField, SortOrder)>>,
group_dirs: Arc<Mutex<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: Arc::new(Mutex::new((default_sort_field, default_sort_order))),
group_dirs: Arc::new(Mutex::new(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 == 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 = Arc::new(Mutex::new((sort_field, sort_order)));
let source = DirListingSource {
vault: self.vault.clone(),
dir,
sort: self.sort.clone(),
group_dirs: self.group_dirs.clone(),
};
self.list = Some(
SearchList::builder(source, redraw_callback(tx.clone()))
.filter(Filter::Fuzzy)
.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.lock().unwrap()
}
pub fn group_dirs(&self) -> bool {
*self.group_dirs.lock().unwrap()
}
pub fn apply_sort(&mut self, field: SortField, order: SortOrder, group_dirs: bool) {
*self.sort.lock().unwrap() = (field, order);
*self.group_dirs.lock().unwrap() = group_dirs;
if let Some(list) = &mut self.list {
list.reload();
}
}
pub fn is_current_journal(&self) -> bool {
&self.current_dir == 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}");
}
}
});
}
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::ShowFileOpsMenu(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::Intercepted(_) | 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::FocusEditor, "editor \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 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"
);
}
}