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 async fn restore_target(&self, vault_reachable: bool, tx: &AppTx) -> VaultPath {
51 let history = self.settings.read().unwrap().current_last_paths();
52 let Some(vault) = self.vault.as_ref().filter(|_| vault_reachable) else {
56 return history.first().cloned().unwrap_or_else(VaultPath::root);
57 };
58 let mut live = Vec::with_capacity(history.len());
59 let mut gone = Vec::new();
60 for path in &history {
61 if vault
64 .entry_kind(path)
65 .await
66 .is_err_and(|e| e.is_not_found())
67 {
68 gone.push(path.clone());
69 } else {
70 live.push(path.clone());
71 }
72 }
73 if !gone.is_empty() {
74 self.settings.read().unwrap().edit_path_history(|paths| {
75 paths.retain(|p| !gone.iter().any(|g| g.is_like(p)));
76 true
77 });
78 }
79 if let Some(last) = history.first()
80 && live.first() != Some(last)
81 {
82 tx.send(AppEvent::ParkFlash(format!(
83 "{last} is gone (moved or deleted outside kimün)"
84 )))
85 .ok();
86 }
87 live.first().cloned().unwrap_or_else(VaultPath::root)
88 }
89}
90
91#[async_trait]
92impl AppScreen for StartScreen {
93 async fn on_enter(&mut self, tx: &AppTx) {
94 if let Some(vault) = self.vault.clone() {
95 let tx2 = tx.clone();
96 let handle = tokio::spawn(async move {
97 match vault.validate_and_init().await {
98 Ok(report) => {
99 tx2.send(AppEvent::IndexingDone(Ok(report.duration))).ok();
100 }
101 Err(e @ VaultError::CaseConflict { .. }) => {
102 tx2.send(AppEvent::VaultConflict(e.to_string())).ok();
106 }
107 Err(e) => {
108 tx2.send(AppEvent::IndexingDone(Err(e.to_string()))).ok();
109 }
110 }
111 });
112 self.overlay = Some(spawn_running(handle, tx));
113 } else {
114 let path = self.restore_target(false, tx).await;
115 tx.send(AppEvent::open(path)).ok();
116 }
117 }
118
119 fn get_kind(&self) -> ScreenKind {
120 ScreenKind::Start
121 }
122
123 fn handle_input(&mut self, _event: &InputEvent, _tx: &AppTx) -> EventState {
124 if matches!(self.overlay, Some(IndexingProgressState::Running { .. })) {
125 return EventState::Consumed;
126 }
127 EventState::NotConsumed
128 }
129
130 async fn handle_app_message(&mut self, msg: AppEvent, tx: &AppTx) {
131 if let AppEvent::IndexingDone(result) = &msg {
132 self.overlay = None;
133 let path = self.restore_target(result.is_ok(), tx).await;
134 tx.send(AppEvent::open(path)).ok();
135 }
136 }
137
138 fn render(&mut self, f: &mut ratatui::Frame) {
139 if let Some(ref state) = self.overlay {
140 render_indexing_overlay(
141 f,
142 state,
143 &mut self.throbber_state,
144 &self.theme,
145 "Initializing vault…",
146 );
147 return;
148 }
149 let block = ratatui::widgets::Block::default()
150 .title("Start app")
151 .borders(ratatui::widgets::Borders::ALL);
152 f.render_widget(block, f.area());
153 }
154}
155
156#[cfg(test)]
157mod tests {
158 use std::time::Duration;
159
160 use super::*;
161 use crate::settings::AppSettings;
162 use crate::test_support::{key_event, temp_vault};
163 use kimun_core::VaultConfig;
164 use ratatui::crossterm::event::KeyCode;
165 use std::sync::{Arc, RwLock};
166 use tokio::sync::mpsc::unbounded_channel;
167
168 fn shared_defaults() -> SharedSettings {
169 Arc::new(RwLock::new(AppSettings::default()))
170 }
171
172 async fn make_vault() -> Arc<NoteVault> {
173 temp_vault("start").await
174 }
175
176 #[tokio::test]
177 async fn on_enter_vault_none_sends_open_path() {
178 let (tx, mut rx) = unbounded_channel::<AppEvent>();
179 let mut screen = StartScreen::new(shared_defaults(), None);
180 screen.on_enter(&tx).await;
181 let msg = rx.try_recv().expect("expected a message");
182 assert!(
183 matches!(msg, AppEvent::OpenPath { .. }),
184 "expected OpenPath, got {:?}",
185 msg
186 );
187 assert!(
188 screen.overlay.is_none(),
189 "overlay should be None when vault is None"
190 );
191 }
192
193 #[tokio::test]
194 async fn on_enter_vault_some_sets_overlay_and_defers_open_path() {
195 let (tx, mut rx) = unbounded_channel::<AppEvent>();
196 let vault = make_vault().await;
197 let mut screen = StartScreen::new(shared_defaults(), Some(vault));
198 screen.on_enter(&tx).await;
199 assert!(
200 matches!(screen.overlay, Some(IndexingProgressState::Running { .. })),
201 "overlay should be Running after on_enter with vault"
202 );
203 let messages: Vec<AppEvent> = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
205 let has_open_path = messages
206 .iter()
207 .any(|m| matches!(m, AppEvent::OpenPath { .. }));
208 assert!(
209 !has_open_path,
210 "OpenPath should not be sent immediately when vault is Some"
211 );
212 }
213
214 #[tokio::test]
215 async fn handle_app_message_indexing_done_ok_clears_overlay_and_sends_open_path() {
216 let (tx, mut rx) = unbounded_channel::<AppEvent>();
217 let mut screen = StartScreen::new(shared_defaults(), None);
218 screen.overlay = Some(IndexingProgressState::Running {
219 work: tokio::spawn(async {}),
220 ticker: tokio::spawn(async {}),
221 });
222 screen
223 .handle_app_message(AppEvent::IndexingDone(Ok(Duration::from_secs(1))), &tx)
224 .await;
225 assert!(screen.overlay.is_none(), "overlay should be cleared");
226 let msg = rx.try_recv().expect("expected OpenPath message");
227 assert!(
228 matches!(msg, AppEvent::OpenPath { .. }),
229 "expected OpenPath after indexing done"
230 );
231 }
232
233 #[tokio::test]
234 async fn handle_app_message_indexing_done_err_clears_overlay_and_sends_open_path() {
235 let (tx, mut rx) = unbounded_channel::<AppEvent>();
236 let mut screen = StartScreen::new(shared_defaults(), None);
237 screen.overlay = Some(IndexingProgressState::Running {
238 work: tokio::spawn(async {}),
239 ticker: tokio::spawn(async {}),
240 });
241 screen
242 .handle_app_message(AppEvent::IndexingDone(Err("fail".to_string())), &tx)
243 .await;
244 assert!(
245 screen.overlay.is_none(),
246 "overlay should be cleared on error"
247 );
248 let msg = rx.try_recv().expect("expected OpenPath message");
249 assert!(
250 matches!(msg, AppEvent::OpenPath { .. }),
251 "expected OpenPath even after failed indexing"
252 );
253 }
254
255 async fn restore_fixture(
258 history: &[&str],
259 existing: &[&str],
260 ) -> (StartScreen, SharedSettings, tempfile::TempDir) {
261 let vault = make_vault().await;
262 for note in existing {
263 vault
264 .create_note(&VaultPath::new(*note), "text")
265 .await
266 .unwrap();
267 }
268 let history_dir = tempfile::TempDir::new().unwrap();
269 let settings = AppSettings::for_test_workspace(
270 "ws",
271 vault.workspace_path(),
272 crate::test_support::sys(history_dir.path()),
273 );
274 settings.seed_test_history(history);
275 let settings: SharedSettings = Arc::new(RwLock::new(settings));
276 let screen = StartScreen::new(settings.clone(), Some(vault));
277 (screen, settings, history_dir)
278 }
279
280 async fn restore(screen: &mut StartScreen) -> Vec<AppEvent> {
282 restore_after(screen, Ok(Duration::from_secs(0))).await
283 }
284
285 async fn restore_after(
286 screen: &mut StartScreen,
287 indexing: Result<Duration, String>,
288 ) -> Vec<AppEvent> {
289 let (tx, mut rx) = unbounded_channel::<AppEvent>();
290 screen
291 .handle_app_message(AppEvent::IndexingDone(indexing), &tx)
292 .await;
293 std::iter::from_fn(|| rx.try_recv().ok()).collect()
294 }
295
296 #[cfg(unix)]
302 #[tokio::test]
303 async fn restore_keeps_a_note_it_cannot_check() {
304 use std::os::unix::fs::PermissionsExt;
305
306 let (mut screen, settings, _dir) = restore_fixture(
307 &["locked/note.md", "still.md"],
308 &["locked/note.md", "still.md"],
309 )
310 .await;
311 let locked = screen
312 .vault
313 .as_ref()
314 .unwrap()
315 .workspace_path()
316 .join("locked")
317 .into_path_buf();
318 std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)).unwrap();
319 restore(&mut screen).await;
320 std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).unwrap();
321
322 assert_eq!(
323 settings.read().unwrap().current_last_paths(),
324 vec![VaultPath::new("locked/note.md"), VaultPath::new("still.md")]
325 );
326 }
327
328 #[tokio::test]
329 async fn restore_after_failed_indexing_leaves_the_history_alone() {
330 let (mut screen, settings, _dir) =
331 restore_fixture(&["gone.md", "still.md"], &["still.md"]).await;
332 let events = restore_after(&mut screen, Err("unreachable".to_string())).await;
333 assert_eq!(
334 settings.read().unwrap().current_last_paths(),
335 vec![VaultPath::new("gone.md"), VaultPath::new("still.md")]
336 );
337 assert!(parked_flashes(&events).is_empty());
338 }
339
340 fn opened(events: &[AppEvent]) -> Vec<VaultPath> {
341 events
342 .iter()
343 .filter_map(|e| match e {
344 AppEvent::OpenPath { path, .. } => Some(path.clone()),
345 _ => None,
346 })
347 .collect()
348 }
349
350 #[tokio::test]
351 async fn restore_skips_a_last_note_gone_from_disk() {
352 let (mut screen, _settings, _dir) =
353 restore_fixture(&["gone.md", "still.md"], &["still.md"]).await;
354 let events = restore(&mut screen).await;
355 assert_eq!(opened(&events), vec![VaultPath::new("still.md")]);
356 }
357
358 fn parked_flashes(events: &[AppEvent]) -> Vec<String> {
359 events
360 .iter()
361 .filter_map(|e| match e {
362 AppEvent::ParkFlash(msg) => Some(msg.clone()),
363 _ => None,
364 })
365 .collect()
366 }
367
368 #[tokio::test]
369 async fn restore_tells_the_user_their_last_note_is_gone() {
370 let (mut screen, _settings, _dir) =
371 restore_fixture(&["gone.md", "still.md"], &["still.md"]).await;
372 let events = restore(&mut screen).await;
373 let flashes = parked_flashes(&events);
374 assert_eq!(
375 flashes.len(),
376 1,
377 "expected one parked flash, got {flashes:?}"
378 );
379 assert!(
380 flashes[0].contains("gone.md"),
381 "flash must name the note: {flashes:?}"
382 );
383 }
384
385 #[tokio::test]
388 async fn restore_is_silent_when_only_older_entries_are_gone() {
389 let (mut screen, _settings, _dir) =
390 restore_fixture(&["still.md", "gone.md"], &["still.md"]).await;
391 let events = restore(&mut screen).await;
392 assert!(parked_flashes(&events).is_empty());
393 }
394
395 #[tokio::test]
396 async fn restore_lands_on_the_root_when_no_history_entry_survives() {
397 let (mut screen, _settings, _dir) = restore_fixture(&["a.md", "b.md"], &[]).await;
398 let events = restore(&mut screen).await;
399 assert_eq!(opened(&events), vec![VaultPath::root()]);
400 }
401
402 #[tokio::test]
403 async fn restore_drops_notes_gone_from_disk_from_the_history() {
404 let (mut screen, settings, _dir) =
405 restore_fixture(&["gone.md", "still.md", "also_gone.md"], &["still.md"]).await;
406 restore(&mut screen).await;
407 assert_eq!(
408 settings.read().unwrap().current_last_paths(),
409 vec![VaultPath::new("still.md")]
410 );
411 }
412
413 #[tokio::test]
414 async fn handle_input_blocked_while_overlay_running() {
415 let (tx, mut rx) = unbounded_channel::<AppEvent>();
416 let mut screen = StartScreen::new(shared_defaults(), None);
417 screen.overlay = Some(IndexingProgressState::Running {
418 work: tokio::spawn(async {}),
419 ticker: tokio::spawn(async {}),
420 });
421 let state = screen.handle_input(&key_event(KeyCode::Enter), &tx);
422 assert!(
423 matches!(state, EventState::Consumed),
424 "input should be consumed while overlay is running"
425 );
426 let messages: Vec<AppEvent> = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
428 let has_non_redraw = messages.iter().any(|m| !matches!(m, AppEvent::Redraw));
429 assert!(
430 !has_non_redraw,
431 "handle_input should not send non-Redraw messages"
432 );
433 }
434
435 #[tokio::test]
436 async fn handle_input_not_consumed_while_overlay_none() {
437 let (tx, _rx) = unbounded_channel::<AppEvent>();
438 let mut screen = StartScreen::new(shared_defaults(), None);
439 screen.overlay = None;
440 let state = screen.handle_input(&key_event(KeyCode::Enter), &tx);
441 assert!(
442 matches!(state, EventState::NotConsumed),
443 "input should not be consumed when overlay is None"
444 );
445 }
446
447 #[cfg(target_os = "linux")]
450 #[tokio::test]
451 async fn on_enter_case_conflict_sends_vault_conflict_not_indexing_done() {
452 let tmp = tempfile::TempDir::new().unwrap();
453 std::fs::write(tmp.path().join("note.md"), "a").unwrap();
454 std::fs::write(tmp.path().join("Note.md"), "b").unwrap();
455
456 let vault = Arc::new(
457 NoteVault::new(VaultConfig::new(crate::test_support::sys(tmp.path())))
458 .await
459 .unwrap(),
460 );
461 let (tx, mut rx) = unbounded_channel::<AppEvent>();
462 let mut screen = StartScreen::new(shared_defaults(), Some(vault));
463 screen.on_enter(&tx).await;
464
465 let conflict_msg = loop {
467 let msg = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
468 .await
469 .expect("timed out waiting for VaultConflict")
470 .expect("channel closed");
471
472 match msg {
473 AppEvent::VaultConflict(details) => break details,
474 AppEvent::Redraw => continue,
475 AppEvent::IndexingDone(_) => panic!("expected VaultConflict, got IndexingDone"),
476 _ => continue,
477 }
478 };
479
480 assert!(
481 conflict_msg.contains("note.md") && conflict_msg.contains("Note.md"),
482 "conflict message should name both files, got: {}",
483 conflict_msg
484 );
485 }
486}