Skip to main content

kimun_notes/app_screen/
start.rs

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    /// Where the restored session lands: the newest history entry still on
37    /// disk, or the vault root when none is. Notes moved or deleted outside
38    /// kimün are skipped, not offered for re-creation — the user asked for
39    /// their last note, not for a new one — and dropped from the history so
40    /// they stop turning up in recents.
41    ///
42    /// When the note it would have landed on is the one gone, it parks a
43    /// flash saying so: the editor opening somewhere else unannounced reads
44    /// as kimün losing the note.
45    ///
46    /// Only trusted after a successful index (`vault_reachable`): when the
47    /// vault itself can't be read every note looks gone, and pruning then
48    /// would wipe the history for good. It lands on the newest entry
49    /// unchecked instead, as it did before any of this existed.
50    async fn restore_target(&self, vault_reachable: bool, tx: &AppTx) -> VaultPath {
51        let history = self.settings.read().unwrap().current_last_paths();
52        // No vault (no workspace — the open is routed to onboarding) or one
53        // that failed to index: nothing trustworthy to check the history
54        // against.
55        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            // Gone means the filesystem said not found. Any other failure
62            // (permissions, I/O) keeps the entry: it may well still be there.
63            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                        // Route structural vault conflicts to VaultConflict so the main
103                        // loop can clear the vault path and redirect to settings.
104                        // To support a future VaultError conflict type: add one arm here.
105                        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        // Drain all messages and ensure none are OpenPath
204        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    /// A vault holding `existing` notes, and settings whose history for it is
256    /// `history` (newest first). Returns the history dir guard with the rest.
257    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    /// Runs the post-indexing restore and returns every event it sent.
281    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    /// A failed index usually means the vault itself is out of reach (an
297    /// unmounted drive, a sync folder not there yet). Every note looks
298    /// missing then, and pruning would wipe the history for good.
299    /// Only a note the filesystem reports as not found is gone. One it can't
300    /// look at right now (permissions, I/O) may well still be there.
301    #[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    /// Only the note the user expected to land on is worth a word; older
386    /// entries vanishing from recents is not news.
387    #[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        // Drain the ticker Redraw messages but confirm no other app-level messages
427        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    // Linux only: macOS and Windows filesystems are case-insensitive by default,
448    // so creating note.md + Note.md would silently overwrite on those platforms.
449    #[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        // Drain events until VaultConflict arrives; skip Redraw ticks from the spinner.
466        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}