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 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 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 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 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 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 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 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 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}