Skip to main content

kimun_notes/components/
sidebar.rs

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        // Apply the appropriate sort defaults for this directory.
71        if &current_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
150/// Format a `NaiveDate` as a human-readable string with day-of-week.
151/// Example: "Wednesday, March 17, 2026"
152fn 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                        // row 0 of the list block is the border; rows start at y+1.
171                        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        // After a key that modifies the search query, keep the create entry in sync.
197        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    /// Regression: clicking on the sidebar header (directory name + note count)
283    /// or the search box must focus the sidebar, not just clicks on the file list.
284    #[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        // Click inside the header (top-of-sidebar) area.
296        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        // Click inside the search-box area (rows 6..9 within the sidebar layout).
314        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    /// Two clicks on the same list row activate it: first selects, second sends
339    /// `OpenPath` (or, for `CreateNote`, materialises the note then opens it).
340    #[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        // First click: in list area, on the first row (list_rect.y + 1).
359        sidebar.handle_input(&mouse_down_at(5, 10), &tx);
360        // Drain the FocusSidebar event from the first click.
361        let _ = rx.try_recv();
362
363        // Second click on the same row activates the entry.
364        sidebar.handle_input(&mouse_down_at(5, 10), &tx);
365        // First event from the second click is FocusSidebar; the next is OpenPath.
366        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    /// Scroll wheel anywhere in the sidebar bounds scrolls the file list — even
379    /// when the cursor is over the header or search box.
380    #[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        // Pin selection to index 0 so ScrollDown moves it to 1.
386        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        // Scroll down with the cursor inside the sidebar header (not the list).
403        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        // Click to the right of the sidebar (in the editor area).
424        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}