Skip to main content

kimun_notes/app_screen/
browse.rs

1use std::sync::Arc;
2
3use async_trait::async_trait;
4use kimun_core::NoteVault;
5use kimun_core::nfs::VaultPath;
6use ratatui::Frame;
7use ratatui::layout::{Constraint, Direction, Layout};
8use ratatui::style::Style;
9use ratatui::widgets::{Block, Paragraph};
10
11use crate::app_screen::{AppScreen, ScreenKind};
12use crate::components::Component;
13use crate::components::event_state::EventState;
14use crate::components::events::{AppEvent, AppTx, FileOp, InputEvent};
15use crate::components::sidebar::SidebarComponent;
16use crate::keys::action_shortcuts::ActionShortcuts;
17use crate::keys::key_event_to_combo;
18use crate::settings::SharedSettings;
19use crate::settings::themes::Theme;
20
21pub struct BrowseScreen {
22    sidebar: SidebarComponent,
23    theme: Theme,
24    settings: SharedSettings,
25}
26
27impl BrowseScreen {
28    pub fn new(vault: Arc<NoteVault>, path: VaultPath, settings: SharedSettings) -> Self {
29        let s = settings.read().unwrap();
30        let theme = s.get_theme();
31        let mut sidebar = SidebarComponent::from_settings(vault, &s);
32        drop(s);
33        // The sidebar's `current_dir` is the single source of truth for the
34        // browsed directory; seed it so `on_enter` opens at `path`.
35        sidebar.set_current_dir(path);
36        Self {
37            sidebar,
38            theme,
39            settings,
40        }
41    }
42
43    async fn navigate_sidebar(&mut self, dir: VaultPath, tx: &AppTx) {
44        // The sidebar hosts a streamed `SearchList`; (re)building its engine for
45        // `dir` runs `browse_vault_stream` inside the source and emits rows as they
46        // arrive. `navigate` updates the sidebar's `current_dir`.
47        self.sidebar.navigate(dir, tx);
48    }
49}
50
51#[async_trait]
52impl AppScreen for BrowseScreen {
53    fn get_kind(&self) -> ScreenKind {
54        ScreenKind::Browse
55    }
56
57    async fn on_enter(&mut self, tx: &AppTx) {
58        self.navigate_sidebar(self.sidebar.current_dir().clone(), tx)
59            .await;
60    }
61
62    fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
63        // Intercept the journal shortcut (Ctrl+J by default) so today's entry
64        // can be opened straight from Browse; everything else feeds the sidebar.
65        if let InputEvent::Key(key) = event
66            && let Some(combo) = key_event_to_combo(key)
67        {
68            let action = {
69                let s = self.settings.read().unwrap();
70                s.key_bindings.get_action(&combo)
71            };
72            if action == Some(ActionShortcuts::NewJournal) {
73                // App-level OpenJournal resolves today's entry and routes it
74                // via OpenPath, switching to the editor (Browse does not open
75                // notes itself).
76                tx.send(AppEvent::OpenJournal).ok();
77                return EventState::Consumed;
78            }
79        }
80        self.sidebar.handle_input(event, tx)
81    }
82
83    async fn handle_app_message(&mut self, msg: AppEvent, tx: &AppTx) {
84        if let AppEvent::FileOp(FileOp::Created(path)) = msg {
85            // A note was created somewhere; rebuild the listing only when we are
86            // browsing its directory so the new note shows up.
87            let (parent, _) = path.get_parent_path();
88            self.sidebar.refresh_if_showing(&parent, tx);
89        }
90    }
91
92    fn render(&mut self, f: &mut Frame) {
93        f.render_widget(Block::default().style(self.theme.base_style()), f.area());
94
95        // Split into content area + one-line hint bar at the bottom.
96        let rows = Layout::default()
97            .direction(Direction::Vertical)
98            .constraints([Constraint::Min(0), Constraint::Length(1)])
99            .split(f.area());
100
101        let cols = Layout::default()
102            .direction(Direction::Horizontal)
103            .constraints([
104                Constraint::Min(0),
105                Constraint::Length(60),
106                Constraint::Min(0),
107            ])
108            .split(rows[0]);
109
110        self.sidebar.render(f, cols[1], &self.theme, true);
111
112        f.render_widget(
113            Paragraph::new(
114                " Type to filter  ·  Enter to open  ·  Type + Enter to create a new note",
115            )
116            .style(
117                Style::default()
118                    .fg(self.theme.gray.to_ratatui())
119                    .bg(self.theme.bg.to_ratatui()),
120            ),
121            rows[1],
122        );
123    }
124
125    async fn try_open_path(
126        &mut self,
127        path: VaultPath,
128        _emphasis: Option<Vec<String>>,
129        tx: &AppTx,
130    ) -> Option<VaultPath> {
131        if !path.is_note() {
132            self.navigate_sidebar(path, tx).await;
133            return None;
134        }
135        // Notes are not handled by Browse — bubble up so the main loop opens
136        // the editor for them.
137        Some(path)
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use crate::components::events::AppEvent;
145    use crate::settings::AppSettings;
146    use crate::test_support::{key_event, temp_vault};
147    use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
148    use std::sync::RwLock;
149    use tokio::sync::mpsc::unbounded_channel;
150
151    fn make_settings_with_defaults() -> SharedSettings {
152        Arc::new(RwLock::new(AppSettings::default()))
153    }
154
155    async fn make_vault() -> Arc<NoteVault> {
156        temp_vault("browse").await
157    }
158
159    #[tokio::test]
160    async fn new_seeds_sidebar_dir() {
161        let vault = make_vault().await;
162        let settings = make_settings_with_defaults();
163        let path = VaultPath::new("subdir");
164        let screen = BrowseScreen::new(vault, path.clone(), settings);
165        assert_eq!(screen.sidebar.current_dir(), &path);
166    }
167
168    #[tokio::test]
169    async fn esc_does_not_quit() {
170        // Quit is a global shortcut of the App loop (`app::run_app`); BrowseScreen ignores Esc.
171        let vault = make_vault().await;
172        let settings = make_settings_with_defaults();
173        let (tx, mut rx) = unbounded_channel();
174        let mut screen = BrowseScreen::new(vault, VaultPath::root(), settings);
175        screen.handle_input(&key_event(KeyCode::Esc), &tx);
176        assert!(
177            rx.try_recv().is_err(),
178            "Esc should not send any message from BrowseScreen"
179        );
180    }
181
182    #[tokio::test]
183    async fn ctrl_j_requests_journal() {
184        let vault = make_vault().await;
185        let settings = make_settings_with_defaults();
186        let (tx, mut rx) = unbounded_channel();
187        let mut screen = BrowseScreen::new(vault, VaultPath::root(), settings);
188        let ctrl_j = InputEvent::Key(KeyEvent {
189            code: KeyCode::Char('j'),
190            modifiers: KeyModifiers::CONTROL,
191            kind: KeyEventKind::Press,
192            state: KeyEventState::NONE,
193        });
194        let state = screen.handle_input(&ctrl_j, &tx);
195        assert_eq!(state, EventState::Consumed, "Ctrl+J should be consumed");
196        assert!(
197            matches!(rx.try_recv(), Ok(AppEvent::OpenJournal)),
198            "Ctrl+J should request the journal entry"
199        );
200    }
201
202    #[tokio::test]
203    async fn try_open_path_dir_is_consumed() {
204        let vault = make_vault().await;
205        let settings = make_settings_with_defaults();
206        let (tx, _rx) = unbounded_channel();
207        let dir = VaultPath::new("subdir");
208        let mut screen = BrowseScreen::new(vault, VaultPath::root(), settings);
209        let result = screen.try_open_path(dir.clone(), None, &tx).await;
210        assert!(result.is_none(), "dir path should be consumed");
211        assert_eq!(
212            screen.sidebar.current_dir(),
213            &dir,
214            "sidebar dir should be updated"
215        );
216    }
217
218    #[tokio::test]
219    async fn try_open_path_note_is_forwarded() {
220        let vault = make_vault().await;
221        let settings = make_settings_with_defaults();
222        let (tx, _rx) = unbounded_channel();
223        let note = VaultPath::note_path_from("test.md");
224        let mut screen = BrowseScreen::new(vault, VaultPath::root(), settings);
225        let result = screen.try_open_path(note.clone(), None, &tx).await;
226        assert_eq!(
227            result,
228            Some(note),
229            "note path should bubble up unchanged for the editor"
230        );
231    }
232}