1use std::sync::Arc;
2use std::sync::mpsc::Receiver;
3
4use crate::settings::themes::Theme;
5use chrono::NaiveDate;
6use kimun_core::SearchResult;
7use kimun_core::nfs::VaultPath;
8use kimun_core::{NoteVault, ResultType};
9use ratatui::Frame;
10use ratatui::crossterm::event::{KeyCode, MouseButton, MouseEventKind};
11use ratatui::layout::{Constraint, Direction, Layout, Position, Rect};
12use ratatui::style::Style;
13use ratatui::widgets::{Block, Borders, Paragraph};
14
15use crate::components::Component;
16use crate::components::event_state::EventState;
17use crate::components::events::{AppEvent, AppTx, InputEvent};
18use crate::components::file_list::{FileListComponent, FileListEntry, SortField, SortOrder};
19use crate::keys::KeyBindings;
20use crate::settings::AppSettings;
21use crate::settings::icons::Icons;
22
23pub struct SidebarComponent {
24 current_dir: VaultPath,
25 pub file_list: FileListComponent,
26 pending_rx: Option<Receiver<SearchResult>>,
27 vault: Arc<NoteVault>,
28 default_sort_field: SortField,
29 default_sort_order: SortOrder,
30 journal_sort_field: SortField,
31 journal_sort_order: SortOrder,
32 rendered_rect: Rect,
33 list_rect: Rect,
34}
35
36impl SidebarComponent {
37 pub fn new(
38 key_bindings: KeyBindings,
39 vault: Arc<NoteVault>,
40 icons: Icons,
41 settings: &AppSettings,
42 ) -> Self {
43 Self {
44 current_dir: VaultPath::root(),
45 file_list: FileListComponent::new(key_bindings, icons),
46 pending_rx: None,
47 vault,
48 default_sort_field: SortField::from(settings.default_sort_field),
49 default_sort_order: SortOrder::from(settings.default_sort_order),
50 journal_sort_field: SortField::from(settings.journal_sort_field),
51 journal_sort_order: SortOrder::from(settings.journal_sort_order),
52 rendered_rect: Rect::default(),
53 list_rect: Rect::default(),
54 }
55 }
56
57 pub fn current_dir(&self) -> &VaultPath {
58 &self.current_dir
59 }
60
61 pub fn is_empty(&self) -> bool {
62 self.file_list.is_empty()
63 }
64
65 pub fn start_loading(&mut self, rx: Receiver<SearchResult>, current_dir: VaultPath) {
66 self.current_dir = current_dir.clone();
67 self.file_list.clear();
68 self.file_list.loading = true;
69
70 if ¤t_dir == self.vault.journal_path() {
72 self.file_list.sort_field = self.journal_sort_field;
73 self.file_list.sort_order = self.journal_sort_order;
74 } else {
75 self.file_list.sort_field = self.default_sort_field;
76 self.file_list.sort_order = self.default_sort_order;
77 }
78
79 if !current_dir.is_root_or_empty() {
80 let parent = current_dir.get_parent_path().0;
81 self.file_list.add_up_entry(parent);
82 }
83
84 self.pending_rx = Some(rx);
85 self.sync_create_entry();
86 }
87
88 fn sync_create_entry(&mut self) {
89 if self.file_list.search_query.is_empty() {
90 self.file_list.set_create_entry(None);
91 } else {
92 let path = self
93 .current_dir
94 .append(&VaultPath::note_path_from(
95 self.file_list.search_query.value(),
96 ))
97 .flatten();
98 let filename = path.to_string();
99 self.file_list
100 .set_create_entry(Some(FileListEntry::CreateNote { filename, path }));
101 }
102 }
103
104 fn activate_selected_entry(&self, tx: &AppTx) {
105 if let Some(FileListEntry::CreateNote { path, .. }) = self.file_list.selected_entry() {
106 let path = path.clone();
107 let vault = Arc::clone(&self.vault);
108 let tx2 = tx.clone();
109 tokio::spawn(async move {
110 if let Err(e) = vault.load_or_create_note(&path, None).await {
111 tracing::warn!("create note failed for {path}: {e}");
112 return;
113 }
114 tx2.send(AppEvent::OpenPath(path)).ok();
115 });
116 return;
117 }
118 self.file_list.activate_selected(tx);
119 }
120
121 fn poll_loading(&mut self) {
122 let Some(rx) = &self.pending_rx else { return };
123 loop {
124 match rx.try_recv() {
125 Ok(result) => {
126 if matches!(&result.rtype, ResultType::Directory)
127 && result.path == self.current_dir
128 {
129 continue;
130 }
131 let journal_date = self
132 .vault
133 .journal_date(&result.path)
134 .map(format_journal_date);
135 self.file_list
136 .push_entry(FileListEntry::from_result(result, journal_date));
137 }
138 Err(std::sync::mpsc::TryRecvError::Empty) => break,
139 Err(std::sync::mpsc::TryRecvError::Disconnected) => {
140 self.pending_rx = None;
141 self.file_list.loading = false;
142 self.file_list.finalize_sort();
143 break;
144 }
145 }
146 }
147 }
148}
149
150fn format_journal_date(date: NaiveDate) -> String {
153 date.format("%A, %B %-d, %Y").to_string()
154}
155
156impl Component for SidebarComponent {
157 fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
158 if let InputEvent::Mouse(mouse) = event {
159 let pos = Position {
160 x: mouse.column,
161 y: mouse.row,
162 };
163 if !self.rendered_rect.contains(pos) {
164 return EventState::NotConsumed;
165 }
166 match mouse.kind {
167 MouseEventKind::Down(MouseButton::Left) => {
168 tx.send(AppEvent::FocusSidebar).ok();
169 if self.list_rect.contains(pos) && mouse.row > self.list_rect.y {
170 let rel_row = mouse.row - self.list_rect.y - 1;
172 let prev = self.file_list.selected_display_idx();
173 if let Some(idx) = self.file_list.select_at_visual_row(rel_row)
174 && prev == Some(idx)
175 {
176 self.activate_selected_entry(tx);
177 }
178 }
179 }
180 MouseEventKind::ScrollUp => self.file_list.scroll_up(),
181 MouseEventKind::ScrollDown => self.file_list.scroll_down(),
182 _ => {}
183 }
184 return EventState::Consumed;
185 }
186
187 if let InputEvent::Key(key) = event
188 && key.code == KeyCode::Enter
189 {
190 self.activate_selected_entry(tx);
191 return EventState::Consumed;
192 }
193
194 let result = self.file_list.handle_input(event, tx);
195
196 if let InputEvent::Key(key) = event
198 && matches!(key.code, KeyCode::Char(_) | KeyCode::Backspace)
199 {
200 self.sync_create_entry();
201 }
202
203 result
204 }
205
206 fn hint_shortcuts(&self) -> Vec<(String, String)> {
207 self.file_list.hint_shortcuts()
208 }
209
210 fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
211 self.poll_loading();
212 self.rendered_rect = rect;
213
214 let rows = Layout::default()
215 .direction(Direction::Vertical)
216 .constraints([
217 Constraint::Length(3),
218 Constraint::Length(3),
219 Constraint::Min(0),
220 ])
221 .split(rect);
222 self.list_rect = rows[2];
223
224 let border_style = theme.border_style(focused);
225
226 let header = Block::default()
227 .title(self.current_dir.to_string())
228 .borders(Borders::ALL)
229 .border_style(border_style)
230 .style(theme.panel_style());
231 let header_inner = header.inner(rows[0]);
232 f.render_widget(header, rows[0]);
233 f.render_widget(
234 Paragraph::new(format!("{} notes", self.file_list.note_count())).style(
235 Style::default()
236 .fg(theme.fg_muted.to_ratatui())
237 .bg(theme.bg_panel.to_ratatui()),
238 ),
239 header_inner,
240 );
241
242 let search_block = Block::default()
243 .title(" Search")
244 .borders(Borders::ALL)
245 .border_style(border_style)
246 .style(theme.panel_style());
247 let search_inner = search_block.inner(rows[1]);
248 f.render_widget(search_block, rows[1]);
249 self.file_list.search_query.render(
250 f,
251 search_inner,
252 Style::default()
253 .fg(theme.fg.to_ratatui())
254 .bg(theme.bg_panel.to_ratatui()),
255 0,
256 focused,
257 );
258
259 self.file_list.render(f, rows[2], theme, focused);
260 }
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266 use crate::settings::AppSettings;
267 use crate::test_support::{mouse_down_at, temp_vault};
268 use ratatui::crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
269 use tokio::sync::mpsc::unbounded_channel;
270
271 async fn make_sidebar() -> SidebarComponent {
272 let vault = temp_vault("sidebar").await;
273 let settings = AppSettings::default();
274 SidebarComponent::new(
275 settings.key_bindings.clone(),
276 vault,
277 settings.icons(),
278 &settings,
279 )
280 }
281
282 #[tokio::test]
285 async fn mouse_down_on_header_focuses_sidebar() {
286 let mut sidebar = make_sidebar().await;
287 sidebar.rendered_rect = Rect {
288 x: 0,
289 y: 3,
290 width: 30,
291 height: 20,
292 };
293 let (tx, mut rx) = unbounded_channel();
294
295 let result = sidebar.handle_input(&mouse_down_at(5, 4), &tx);
297 assert_eq!(result, EventState::Consumed);
298 let evt = rx.try_recv().expect("should send a focus event");
299 assert!(matches!(evt, AppEvent::FocusSidebar));
300 }
301
302 #[tokio::test]
303 async fn mouse_down_on_search_box_focuses_sidebar() {
304 let mut sidebar = make_sidebar().await;
305 sidebar.rendered_rect = Rect {
306 x: 0,
307 y: 3,
308 width: 30,
309 height: 20,
310 };
311 let (tx, mut rx) = unbounded_channel();
312
313 let result = sidebar.handle_input(&mouse_down_at(5, 7), &tx);
315 assert_eq!(result, EventState::Consumed);
316 let evt = rx.try_recv().expect("should send a focus event");
317 assert!(matches!(evt, AppEvent::FocusSidebar));
318 }
319
320 fn scroll_event_at(col: u16, row: u16, kind: MouseEventKind) -> InputEvent {
321 InputEvent::Mouse(MouseEvent {
322 kind,
323 column: col,
324 row,
325 modifiers: KeyModifiers::NONE,
326 })
327 }
328
329 fn push_note(sidebar: &mut SidebarComponent, name: &str) {
330 sidebar.file_list.entries.push(FileListEntry::Note {
331 path: VaultPath::note_path_from(name),
332 title: name.to_string(),
333 filename: format!("{name}.md"),
334 journal_date: None,
335 });
336 }
337
338 #[tokio::test]
341 async fn mouse_double_click_on_list_row_sends_open_path() {
342 let mut sidebar = make_sidebar().await;
343 push_note(&mut sidebar, "alpha");
344 sidebar.rendered_rect = Rect {
345 x: 0,
346 y: 3,
347 width: 30,
348 height: 20,
349 };
350 sidebar.list_rect = Rect {
351 x: 0,
352 y: 9,
353 width: 30,
354 height: 14,
355 };
356 let (tx, mut rx) = unbounded_channel();
357
358 sidebar.handle_input(&mouse_down_at(5, 10), &tx);
360 let _ = rx.try_recv();
362
363 sidebar.handle_input(&mouse_down_at(5, 10), &tx);
365 let mut events = Vec::new();
367 while let Ok(evt) = rx.try_recv() {
368 events.push(evt);
369 }
370 assert!(
371 events
372 .iter()
373 .any(|e| matches!(e, AppEvent::OpenPath(p) if p.to_string().contains("alpha"))),
374 "expected OpenPath for the activated note, got {events:?}"
375 );
376 }
377
378 #[tokio::test]
381 async fn scroll_down_in_sidebar_bounds_scrolls_list() {
382 let mut sidebar = make_sidebar().await;
383 push_note(&mut sidebar, "alpha");
384 push_note(&mut sidebar, "beta");
385 sidebar.file_list.select_at_visual_row(0);
387 sidebar.rendered_rect = Rect {
388 x: 0,
389 y: 3,
390 width: 30,
391 height: 20,
392 };
393 sidebar.list_rect = Rect {
394 x: 0,
395 y: 9,
396 width: 30,
397 height: 14,
398 };
399 let (tx, _rx) = unbounded_channel();
400 assert_eq!(sidebar.file_list.selected_display_idx(), Some(0));
401
402 let result = sidebar.handle_input(&scroll_event_at(5, 4, MouseEventKind::ScrollDown), &tx);
404 assert_eq!(result, EventState::Consumed);
405 assert_eq!(
406 sidebar.file_list.selected_display_idx(),
407 Some(1),
408 "scroll-from-header should still scroll the list"
409 );
410 }
411
412 #[tokio::test]
413 async fn mouse_down_outside_sidebar_is_not_consumed() {
414 let mut sidebar = make_sidebar().await;
415 sidebar.rendered_rect = Rect {
416 x: 0,
417 y: 3,
418 width: 30,
419 height: 20,
420 };
421 let (tx, mut rx) = unbounded_channel();
422
423 let result = sidebar.handle_input(&mouse_down_at(50, 10), &tx);
425 assert_eq!(result, EventState::NotConsumed);
426 assert!(rx.try_recv().is_err());
427 }
428}