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
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                        // Route structural vault conflicts to VaultConflict so the main
49                        // loop can clear the vault path and redirect to settings.
50                        // To support a future VaultError conflict type: add one arm here.
51                        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        // Drain all messages and ensure none are OpenPath
152        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        // Drain the ticker Redraw messages but confirm no other app-level messages
217        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    // Linux only: macOS and Windows filesystems are case-insensitive by default,
238    // so creating note.md + Note.md would silently overwrite on those platforms.
239    #[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        // Drain events until VaultConflict arrives; skip Redraw ticks from the spinner.
256        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}