1use std::sync::Arc;
2
3use async_trait::async_trait;
4use kimun_core::NoteVault;
5use kimun_core::error::VaultError;
6use kimun_core::nfs::VaultPath;
7use throbber_widgets_tui::ThrobberState;
8
9use crate::app_screen::{AppScreen, ScreenKind};
10use crate::components::event_state::EventState;
11use crate::components::events::{AppEvent, AppTx, InputEvent};
12use crate::components::indexing::{IndexingProgressState, render_indexing_overlay, spawn_running};
13use crate::settings::SharedSettings;
14use crate::settings::themes::Theme;
15
16pub struct StartScreen {
17 settings: SharedSettings,
18 theme: Theme,
19 vault: Option<Arc<NoteVault>>,
20 overlay: Option<IndexingProgressState>,
21 throbber_state: ThrobberState,
22}
23
24impl StartScreen {
25 pub fn new(settings: SharedSettings, vault: Option<Arc<NoteVault>>) -> Self {
26 let theme = settings.read().unwrap().get_theme();
27 Self {
28 settings,
29 theme,
30 vault,
31 overlay: None,
32 throbber_state: ThrobberState::default(),
33 }
34 }
35}
36
37#[async_trait]
38impl AppScreen for StartScreen {
39 async fn on_enter(&mut self, tx: &AppTx) {
40 if let Some(vault) = self.vault.clone() {
41 let tx2 = tx.clone();
42 let handle = tokio::spawn(async move {
43 match vault.validate_and_init().await {
44 Ok(report) => {
45 tx2.send(AppEvent::IndexingDone(Ok(report.duration))).ok();
46 }
47 Err(e @ VaultError::CaseConflict { .. }) => {
48 tx2.send(AppEvent::VaultConflict(e.to_string())).ok();
52 }
53 Err(e) => {
54 tx2.send(AppEvent::IndexingDone(Err(e.to_string()))).ok();
55 }
56 }
57 });
58 self.overlay = Some(spawn_running(handle, tx));
59 } else {
60 let paths = self.settings.read().unwrap().current_last_paths();
61 let path = paths.first().map_or_else(VaultPath::root, |p| p.to_owned());
62 tx.send(AppEvent::open(path)).ok();
63 }
64 }
65
66 fn get_kind(&self) -> ScreenKind {
67 ScreenKind::Start
68 }
69
70 fn handle_input(&mut self, _event: &InputEvent, _tx: &AppTx) -> EventState {
71 if matches!(self.overlay, Some(IndexingProgressState::Running { .. })) {
72 return EventState::Consumed;
73 }
74 EventState::NotConsumed
75 }
76
77 async fn handle_app_message(&mut self, msg: AppEvent, tx: &AppTx) {
78 if let AppEvent::IndexingDone(_) = &msg {
79 self.overlay = None;
80 let paths = self.settings.read().unwrap().current_last_paths();
81 let path = paths.first().map_or_else(VaultPath::root, |p| p.to_owned());
82 tx.send(AppEvent::open(path)).ok();
83 }
84 }
85
86 fn render(&mut self, f: &mut ratatui::Frame) {
87 if let Some(ref state) = self.overlay {
88 render_indexing_overlay(
89 f,
90 state,
91 &mut self.throbber_state,
92 &self.theme,
93 "Initializing vault…",
94 );
95 return;
96 }
97 let block = ratatui::widgets::Block::default()
98 .title("Start app")
99 .borders(ratatui::widgets::Borders::ALL);
100 f.render_widget(block, f.area());
101 }
102}
103
104#[cfg(test)]
105mod tests {
106 use std::time::Duration;
107
108 use super::*;
109 use crate::settings::AppSettings;
110 use crate::test_support::{key_event, temp_vault};
111 use kimun_core::VaultConfig;
112 use ratatui::crossterm::event::KeyCode;
113 use std::sync::{Arc, RwLock};
114 use tokio::sync::mpsc::unbounded_channel;
115
116 fn shared_defaults() -> SharedSettings {
117 Arc::new(RwLock::new(AppSettings::default()))
118 }
119
120 async fn make_vault() -> Arc<NoteVault> {
121 temp_vault("start").await
122 }
123
124 #[tokio::test]
125 async fn on_enter_vault_none_sends_open_path() {
126 let (tx, mut rx) = unbounded_channel::<AppEvent>();
127 let mut screen = StartScreen::new(shared_defaults(), None);
128 screen.on_enter(&tx).await;
129 let msg = rx.try_recv().expect("expected a message");
130 assert!(
131 matches!(msg, AppEvent::OpenPath { .. }),
132 "expected OpenPath, got {:?}",
133 msg
134 );
135 assert!(
136 screen.overlay.is_none(),
137 "overlay should be None when vault is None"
138 );
139 }
140
141 #[tokio::test]
142 async fn on_enter_vault_some_sets_overlay_and_defers_open_path() {
143 let (tx, mut rx) = unbounded_channel::<AppEvent>();
144 let vault = make_vault().await;
145 let mut screen = StartScreen::new(shared_defaults(), Some(vault));
146 screen.on_enter(&tx).await;
147 assert!(
148 matches!(screen.overlay, Some(IndexingProgressState::Running { .. })),
149 "overlay should be Running after on_enter with vault"
150 );
151 let messages: Vec<AppEvent> = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
153 let has_open_path = messages
154 .iter()
155 .any(|m| matches!(m, AppEvent::OpenPath { .. }));
156 assert!(
157 !has_open_path,
158 "OpenPath should not be sent immediately when vault is Some"
159 );
160 }
161
162 #[tokio::test]
163 async fn handle_app_message_indexing_done_ok_clears_overlay_and_sends_open_path() {
164 let (tx, mut rx) = unbounded_channel::<AppEvent>();
165 let mut screen = StartScreen::new(shared_defaults(), None);
166 screen.overlay = Some(IndexingProgressState::Running {
167 work: tokio::spawn(async {}),
168 ticker: tokio::spawn(async {}),
169 });
170 screen
171 .handle_app_message(AppEvent::IndexingDone(Ok(Duration::from_secs(1))), &tx)
172 .await;
173 assert!(screen.overlay.is_none(), "overlay should be cleared");
174 let msg = rx.try_recv().expect("expected OpenPath message");
175 assert!(
176 matches!(msg, AppEvent::OpenPath { .. }),
177 "expected OpenPath after indexing done"
178 );
179 }
180
181 #[tokio::test]
182 async fn handle_app_message_indexing_done_err_clears_overlay_and_sends_open_path() {
183 let (tx, mut rx) = unbounded_channel::<AppEvent>();
184 let mut screen = StartScreen::new(shared_defaults(), None);
185 screen.overlay = Some(IndexingProgressState::Running {
186 work: tokio::spawn(async {}),
187 ticker: tokio::spawn(async {}),
188 });
189 screen
190 .handle_app_message(AppEvent::IndexingDone(Err("fail".to_string())), &tx)
191 .await;
192 assert!(
193 screen.overlay.is_none(),
194 "overlay should be cleared on error"
195 );
196 let msg = rx.try_recv().expect("expected OpenPath message");
197 assert!(
198 matches!(msg, AppEvent::OpenPath { .. }),
199 "expected OpenPath even after failed indexing"
200 );
201 }
202
203 #[tokio::test]
204 async fn handle_input_blocked_while_overlay_running() {
205 let (tx, mut rx) = unbounded_channel::<AppEvent>();
206 let mut screen = StartScreen::new(shared_defaults(), None);
207 screen.overlay = Some(IndexingProgressState::Running {
208 work: tokio::spawn(async {}),
209 ticker: tokio::spawn(async {}),
210 });
211 let state = screen.handle_input(&key_event(KeyCode::Enter), &tx);
212 assert!(
213 matches!(state, EventState::Consumed),
214 "input should be consumed while overlay is running"
215 );
216 let messages: Vec<AppEvent> = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
218 let has_non_redraw = messages.iter().any(|m| !matches!(m, AppEvent::Redraw));
219 assert!(
220 !has_non_redraw,
221 "handle_input should not send non-Redraw messages"
222 );
223 }
224
225 #[tokio::test]
226 async fn handle_input_not_consumed_while_overlay_none() {
227 let (tx, _rx) = unbounded_channel::<AppEvent>();
228 let mut screen = StartScreen::new(shared_defaults(), None);
229 screen.overlay = None;
230 let state = screen.handle_input(&key_event(KeyCode::Enter), &tx);
231 assert!(
232 matches!(state, EventState::NotConsumed),
233 "input should not be consumed when overlay is None"
234 );
235 }
236
237 #[cfg(target_os = "linux")]
240 #[tokio::test]
241 async fn on_enter_case_conflict_sends_vault_conflict_not_indexing_done() {
242 let tmp = tempfile::TempDir::new().unwrap();
243 std::fs::write(tmp.path().join("note.md"), "a").unwrap();
244 std::fs::write(tmp.path().join("Note.md"), "b").unwrap();
245
246 let vault = Arc::new(
247 NoteVault::new(VaultConfig::new(crate::test_support::sys(tmp.path())))
248 .await
249 .unwrap(),
250 );
251 let (tx, mut rx) = unbounded_channel::<AppEvent>();
252 let mut screen = StartScreen::new(shared_defaults(), Some(vault));
253 screen.on_enter(&tx).await;
254
255 let conflict_msg = loop {
257 let msg = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
258 .await
259 .expect("timed out waiting for VaultConflict")
260 .expect("channel closed");
261
262 match msg {
263 AppEvent::VaultConflict(details) => break details,
264 AppEvent::Redraw => continue,
265 AppEvent::IndexingDone(_) => panic!("expected VaultConflict, got IndexingDone"),
266 _ => continue,
267 }
268 };
269
270 assert!(
271 conflict_msg.contains("note.md") && conflict_msg.contains("Note.md"),
272 "conflict message should name both files, got: {}",
273 conflict_msg
274 );
275 }
276}