kimun_notes/app/mod.rs
1//! The **App loop** and the state it runs over (see CONTEXT.md § App shell).
2//!
3//! Submodules are internal seams: `events` (the **Input source**), `terminal`
4//! (the **Terminal session**), `bootstrap` (logging and the panic hook).
5
6pub(crate) mod bootstrap;
7pub mod events;
8pub(crate) mod terminal;
9
10use std::io;
11use std::process::ExitCode;
12use std::sync::{Arc, RwLock};
13
14use color_eyre::eyre;
15use kimun_core::{NoteVault, VaultConfig};
16use ratatui::Terminal;
17use ratatui::prelude::Backend;
18
19use crate::app_screen::browse::BrowseScreen;
20use crate::app_screen::editor::EditorScreen;
21use crate::app_screen::onboarding::OnboardingScreen;
22use crate::app_screen::preferences::PreferencesScreen;
23use crate::app_screen::start::StartScreen;
24use crate::app_screen::{AppScreen, ScreenKind};
25use crate::components::events::{AppEvent, AppTx, AppTxExt, InputEvent, ScreenEvent, UpdateFlow};
26use crate::keys::action_shortcuts::ActionShortcuts;
27use crate::keys::key_event_to_combo;
28use crate::settings::{AppSettings, SharedSettings};
29use clap::Parser;
30use color_eyre::Result;
31use events::EventHandler;
32use std::path::PathBuf;
33
34#[derive(Parser)]
35#[command(name = "kimun", about = "Kimün notes", version)]
36pub struct Cli {
37 /// Path to a custom config file
38 #[arg(long, value_name = "FILE")]
39 pub config: Option<PathBuf>,
40
41 #[command(subcommand)]
42 pub command: Option<crate::cli::CliCommand>,
43}
44
45/// The process entry the binary delegates to. Owns the runtime: the nvim
46/// backend uses `tokio::task::block_in_place` during construction, which
47/// requires the multi-thread flavor — that constraint lives here, next to the
48/// code that has it, not in the shim.
49pub fn main() -> Result<ExitCode> {
50 color_eyre::install()?;
51 let runtime = tokio::runtime::Builder::new_multi_thread()
52 .enable_all()
53 .build()?;
54 runtime.block_on(entry())
55}
56
57async fn entry() -> Result<ExitCode> {
58 // Computed once, reused by logging and the panic hook. The guard is held
59 // to the end of this function, and every exit path returns through here,
60 // so the log is always flushed.
61 let log_dir: PathBuf = kimun_core::system::log_dir().into_path_buf();
62 let _guard = bootstrap::init_logging(&log_dir);
63 bootstrap::install_panic_hook(log_dir.join("kimun.log"));
64
65 let cli = Cli::parse();
66 match cli.command {
67 Some(command) => run_cli_command(command, cli.config).await,
68 None => {
69 run_tui(cli.config).await?;
70 Ok(ExitCode::SUCCESS)
71 }
72 }
73}
74
75/// A user error (missing/existing note, bad input) prints a clean message and
76/// exits with code 2 — distinct from an internal failure, which keeps the full
77/// color_eyre report (exit 1). The recoverable/internal split is core's
78/// `VaultError::user_message`; the boundary lives here so every CLI command
79/// propagates the typed `VaultError` (via `?`) and renders identically.
80async fn run_cli_command(
81 command: crate::cli::CliCommand,
82 config: Option<PathBuf>,
83) -> Result<ExitCode> {
84 match crate::cli::run_cli(command, config).await {
85 Ok(()) => Ok(ExitCode::SUCCESS),
86 Err(report) => {
87 if let Some(msg) = report
88 .downcast_ref::<kimun_core::error::VaultError>()
89 .and_then(|ve| ve.user_message())
90 {
91 // Returned, not `process::exit`ed: the code unwinds back
92 // through `entry`, so the runtime and the log guard are
93 // dropped normally on the way out.
94 eprintln!("Error: {msg}");
95 return Ok(ExitCode::from(2));
96 }
97 Err(report)
98 }
99 }
100}
101
102/// The TUI: settings and vault first (a startup error prints to a normal
103/// terminal), then the **Terminal session**, the background tasks, the loop.
104/// The session is left before anything else is printed.
105pub async fn run_tui(config_path: Option<PathBuf>) -> Result<()> {
106 let mut app = App::new(config_path).await?;
107 // Read after App::new since the setting lives in its settings (ADR-0015).
108 let mouse_capture = app.settings.read().unwrap().mouse();
109 let mut session = terminal::TerminalSession::enter(mouse_capture)?;
110 let mut events = EventHandler::new();
111
112 spawn_update_check(&app, events.app_sender());
113 respawn_rag(&mut app, &events.app_sender());
114
115 let outcome = run_app(session.terminal_mut(), &mut app, &mut events).await;
116 drop(session);
117
118 if let Err(e) = outcome {
119 tracing::error!("fatal error: {e}");
120 return Err(e.into());
121 }
122 crate::components::text_editor::widener_metrics::dump_if_enabled();
123 Ok(())
124}
125
126pub struct App {
127 /// The live **Screen**. There is always exactly one: the app starts on
128 /// **Start** and every transition swaps the box whole in `switch_screen`,
129 /// so there is no in-between state to represent.
130 pub current_screen: Box<dyn AppScreen>,
131
132 pub settings: SharedSettings,
133
134 /// The active vault. `None` until a workspace path is configured.
135 /// Rebuilt only when the workspace path changes in settings.
136 pub vault: Option<Arc<NoteVault>>,
137
138 /// Monotonic counter bumped by every screen swap (see `switch_screen`
139 /// below). The main event loop breaks its inner drain when this changes,
140 /// so the new screen is drawn before any event still queued is delivered
141 /// to it. The queued events are not dropped — filtering stale results is
142 /// the job of the addressed event families (`OverlayData`, `Ask`) and
143 /// per-event guards, not of this counter.
144 pub screen_generation: u64,
145
146 /// A newer release found by the background update check at startup, if any.
147 /// Seeded into each editor screen so the footer can show the indicator.
148 pub update: Option<crate::update::UpdateStatus>,
149
150 /// The background RAG sync task for the current vault, when a server is
151 /// configured. Aborted and respawned when the vault is rebuilt.
152 pub rag_sync_task: Option<tokio::task::JoinHandle<()>>,
153
154 /// Latest RAG status from the background task, held app-globally so a
155 /// freshly-opened editor can be seeded immediately (like `update`) instead
156 /// of showing nothing until the next sync tick.
157 pub rag_status: crate::rag::RagStatus,
158}
159
160impl App {
161 /// Load settings from `config_path` (or the default location) and build
162 /// the app on them.
163 pub async fn new(config_path: Option<std::path::PathBuf>) -> eyre::Result<Self> {
164 let loaded_settings = match config_path {
165 Some(path) => AppSettings::load_from_file(path)?,
166 None => AppSettings::load_from_disk()?,
167 };
168 Ok(Self::from_settings(Arc::new(RwLock::new(loaded_settings))).await)
169 }
170
171 /// The app over already-loaded settings: opens the vault those settings
172 /// name (if any) and starts on the **Start** screen. The door tests use —
173 /// nothing here touches the config file.
174 pub async fn from_settings(settings: SharedSettings) -> Self {
175 let vault = Self::open_vault(&settings).await;
176 Self {
177 current_screen: Box::new(StartScreen::new(settings.clone(), vault.clone())),
178 settings,
179 vault,
180 screen_generation: 0,
181 update: None,
182 rag_sync_task: None,
183 rag_status: crate::rag::RagStatus::Disabled,
184 }
185 }
186
187 /// A fresh `NoteVault` for whatever workspace the settings currently
188 /// resolve to, wired to the configured index and the workspace's inbox.
189 /// `None` if no workspace is configured or the vault fails to open.
190 ///
191 /// Used at startup and every time the workspace changes (preferences
192 /// saved, onboarding finished, workspace switched).
193 pub async fn open_vault(settings: &SharedSettings) -> Option<Arc<NoteVault>> {
194 let (workspace_path, cache_path, inbox_path) = {
195 let s = settings.read().unwrap();
196 let wp = s.resolve_workspace_path();
197 let name = s.current_workspace_name();
198 let cache = name.as_ref().map(|n| s.index_for(n));
199 let ip = s
200 .workspace_config
201 .as_ref()
202 .and_then(|wc| wc.get_current_workspace())
203 .map(|e| e.effective_inbox_path());
204 (wp, cache, ip)
205 };
206 let workspace = workspace_path?;
207 let mut config = VaultConfig::new(workspace.clone());
208 if let Some(cp) = cache_path {
209 config = config.with_index(cp);
210 }
211 match NoteVault::new(config).await {
212 Ok(mut v) => {
213 if let Some(ref ip) = inbox_path {
214 v.set_inbox_path(kimun_core::nfs::VaultPath::new(ip));
215 }
216 Some(Arc::new(v))
217 }
218 // Don't swallow the cause: since the index self-heal, opening the
219 // vault can fail on a cache probe error (e.g. the cache is locked
220 // by another kimun process). The app falls back to the no-vault
221 // start screen either way, but the reason must reach the log
222 // instead of looking like an unconfigured workspace.
223 Err(e) => {
224 tracing::error!("could not open vault at {}: {e}", workspace);
225 None
226 }
227 }
228 }
229}
230
231/// (Re)starts the background RAG sync for the current vault: aborts any prior
232/// task and spawns a fresh one bound to the current `app.vault`. A no-op sync
233/// (no server configured, or no vault) leaves the task `None`.
234fn respawn_rag(app: &mut App, tx: &crate::components::events::AppTx) {
235 if let Some(handle) = app.rag_sync_task.take() {
236 handle.abort();
237 }
238 if let Some(vault) = app.vault.clone() {
239 app.rag_sync_task = crate::rag::spawn_rag_sync(vault, &app.settings, tx.clone());
240 }
241}
242
243async fn switch_screen(app: &mut App, tx: &AppTx, new_screen: ScreenEvent) {
244 app.current_screen.on_exit(tx).await;
245
246 let mut screen: Box<dyn AppScreen> = match new_screen {
247 ScreenEvent::Start => Box::new(StartScreen::new(app.settings.clone(), app.vault.clone())),
248 ScreenEvent::OpenPreferences => Box::new(PreferencesScreen::new(app.settings.clone())),
249 ScreenEvent::OpenPreferencesWithError(msg) => {
250 Box::new(PreferencesScreen::new_with_error(app.settings.clone(), msg))
251 }
252 ScreenEvent::OpenEditor(note_vault, vault_path) => Box::new(EditorScreen::new(
253 note_vault,
254 vault_path,
255 app.settings.clone(),
256 )),
257 ScreenEvent::OpenBrowse(note_vault, vault_path) => Box::new(BrowseScreen::new(
258 note_vault,
259 vault_path,
260 app.settings.clone(),
261 )),
262 ScreenEvent::OpenOnboarding => Box::new(OnboardingScreen::new(app.settings.clone())),
263 };
264
265 screen.on_enter(tx).await;
266 // Seed the freshly-created screen with any pending update notice, so the
267 // editor footer shows it even though the check finished before this screen
268 // existed. Non-editor screens ignore the event.
269 if let Some(status) = app.update.clone() {
270 screen
271 .handle_app_message(AppEvent::Update(UpdateFlow::Available(status)), tx)
272 .await;
273 }
274 screen
275 .handle_app_message(AppEvent::RagStatus(app.rag_status), tx)
276 .await;
277 app.current_screen = screen;
278 // Bumped here (not at every swap site) because every swap goes through
279 // this function. The main loop watches this counter to break its inner
280 // event drain whenever the screen identity changes, so the new screen is
281 // drawn before any event still queued is delivered to it.
282 app.screen_generation = app.screen_generation.wrapping_add(1);
283}
284
285/// Kick off the background update check (gated on the user's `update_check`
286/// preference). All network/filesystem work runs on `spawn_blocking` inside
287/// `update::check_now`; a found update is surfaced via `AppEvent::Update(UpdateFlow::Available)`. Failures are logged and
288/// swallowed — the check never blocks startup or interaction.
289fn spawn_update_check(app: &App, tx: AppTx) {
290 if !app.settings.read().unwrap().update_check() {
291 return;
292 }
293 let Ok(config_dir) = crate::settings::config_dir() else {
294 return;
295 };
296 tokio::spawn(async move {
297 match crate::update::check_now(config_dir, false).await {
298 Ok(Some(status)) if status.should_notify() => {
299 let _ = tx.send(AppEvent::Update(UpdateFlow::Available(status)));
300 }
301 Ok(_) => {}
302 Err(e) => tracing::debug!("update check failed: {e}"),
303 }
304 });
305}
306
307/// The **App loop** (CONTEXT.md § App shell).
308///
309/// Draw the screen, wait for the next event, drain what is already queued,
310/// draw again. The rules that live here and nowhere else:
311///
312/// - Global shortcuts (`Quit`, `OpenPreferences`) fire before the screen
313/// sees the key.
314/// - Queued app messages are coalesced: one frame per batch. A real input
315/// event always comes through `next()` and so always gets its own draw.
316/// - A screen swap mid-drain ends the drain (`App::screen_generation`), so the
317/// new screen is drawn before any event still queued is delivered to it.
318/// The queued events are *not* dropped — they are read again on the next
319/// iteration and reach the new screen. Filtering stale results is the job
320/// of the addressed event families (`OverlayData`, `Ask`) and per-event
321/// guards, not of this loop.
322/// - `Quit` runs the current screen's `on_exit` before returning.
323///
324/// Generic over the terminal backend and fed by an **Input source**, so it
325/// runs headless in tests (`tests/app_loop_test.rs`).
326pub async fn run_app<B: Backend>(
327 terminal: &mut Terminal<B>,
328 app: &mut App,
329 events: &mut EventHandler,
330) -> io::Result<()>
331where
332 B::Error: std::error::Error + Send + Sync + 'static,
333{
334 let tx = events.app_sender();
335
336 app.current_screen.on_enter(&tx).await;
337
338 loop {
339 terminal
340 .draw(|f| app.current_screen.render(f))
341 // A `From` bound into `io::Error` would exclude `TestBackend`
342 // (`Error = Infallible`), which the headless loop tests use.
343 // Wrapping through `io::Error::other` keeps the source error and
344 // needs only the standard error bounds.
345 .map_err(io::Error::other)?;
346
347 // Block until at least one event arrives, then drain everything else
348 // that is already queued before drawing again. `Redraw` events are
349 // coalesced — the top-of-loop draw paints one frame for the whole
350 // batch instead of one frame per pending message. Crossterm input
351 // events never come through the mpsc channel, so a real key event
352 // always forces a fresh `events.next().await` (and therefore a
353 // dedicated draw) on the next iteration.
354 let mut event = events.next().await;
355 loop {
356 match event {
357 AppEvent::Quit => {
358 app.current_screen.on_exit(&tx).await;
359 return Ok(());
360 }
361 AppEvent::Redraw => {
362 // No-op: top-of-loop draw already happened (or is about to).
363 }
364 AppEvent::Input(input) => {
365 match input {
366 InputEvent::Key(key) => {
367 tracing::debug!(
368 "KEY: code={:?} mods={:?} kind={:?}",
369 key.code,
370 key.modifiers,
371 key.kind
372 );
373 // Global shortcuts — fire before any screen gets the event.
374 if let Some(combo) = key_event_to_combo(&key) {
375 let action = {
376 let s = app.settings.read().unwrap();
377 tracing::debug!(
378 "COMBO: {} → {:?}",
379 combo,
380 s.key_bindings.get_action(&combo)
381 );
382 s.key_bindings.get_action(&combo)
383 };
384 let handled_global = match action {
385 Some(ActionShortcuts::Quit) => {
386 tx.send(AppEvent::Quit).ok();
387 true
388 }
389 Some(ActionShortcuts::OpenPreferences) => {
390 let already_on_settings = app.current_screen.get_kind()
391 == ScreenKind::Preferences;
392 if !already_on_settings {
393 tx.send(AppEvent::OpenScreen(
394 ScreenEvent::OpenPreferences,
395 ))
396 .ok();
397 }
398 true
399 }
400 _ => false,
401 };
402 if handled_global {
403 // Skip screen-level handling for this key.
404 match events.try_next() {
405 Some(next) => {
406 event = next;
407 continue;
408 }
409 None => break,
410 }
411 }
412 }
413 app.current_screen.handle_input(&InputEvent::Key(key), &tx);
414 }
415 InputEvent::Mouse(mouse_event) => {
416 app.current_screen
417 .handle_input(&InputEvent::Mouse(mouse_event), &tx);
418 }
419 InputEvent::Paste(text) => {
420 app.current_screen
421 .handle_input(&InputEvent::Paste(text), &tx);
422 }
423 }
424 }
425 msg => {
426 // Capture screen identity around handle_app_message so we
427 // can detect a synchronous screen swap (OpenScreen,
428 // VaultConflict). Use `screen_generation` rather than
429 // `ScreenKind`, because a swap between two screens of the
430 // same kind (e.g. EditorScreen(A) → follow-link →
431 // EditorScreen(B)) still leaks A's queued events into B
432 // if we only compare kinds. Remaining queued events
433 // belong to the OLD screen instance — break the drain so
434 // they get a fresh outer iteration where they are routed
435 // correctly (and the new screen gets its first draw
436 // before further input).
437 let before_gen = app.screen_generation;
438 handle_app_message(msg, app, &tx).await?;
439 if app.screen_generation != before_gen {
440 break;
441 }
442 }
443 }
444 match events.try_next() {
445 Some(next) => event = next,
446 None => break,
447 }
448 }
449 }
450}
451
452async fn handle_app_message(msg: AppEvent, app: &mut App, tx: &AppTx) -> io::Result<()> {
453 match msg {
454 AppEvent::Redraw => {}
455 AppEvent::OpenScreen(screen) => {
456 switch_screen(app, tx, screen).await;
457 }
458 AppEvent::OpenPath { path, emphasis } => {
459 // We either handle the new path within the current screen, or we switch to a new screen for this path
460 let unhandled = app.current_screen.try_open_path(path, emphasis, tx).await;
461 if let Some(path) = unhandled {
462 if let Some(vault) = app.vault.clone() {
463 if path.is_note() {
464 tx.send(AppEvent::OpenScreen(ScreenEvent::OpenEditor(vault, path)))
465 .ok();
466 } else {
467 tx.send(AppEvent::OpenScreen(ScreenEvent::OpenBrowse(vault, path)))
468 .ok();
469 }
470 } else {
471 // No vault → the app is unconfigured. Route to the guided
472 // setup, not Preferences (onboarding replaces the
473 // preferences fallthrough as the no-workspace path).
474 tx.send(AppEvent::OpenScreen(ScreenEvent::OpenOnboarding))
475 .ok();
476 }
477 }
478 }
479 AppEvent::OpenAttachment(path) => {
480 // The editor screen shows it in its attachment view; any other
481 // screen routes through OpenEditor first, then the attachment opens
482 // there. (In practice this is sent from the editor's FILES drawer.)
483 let unhandled = app.current_screen.try_open_attachment(path, tx).await;
484 if let Some(path) = unhandled
485 && let Some(vault) = app.vault.clone()
486 {
487 tx.send(AppEvent::OpenScreen(ScreenEvent::OpenEditor(vault, path)))
488 .ok();
489 }
490 }
491 AppEvent::OpenJournal => {
492 // Resolve today's journal entry (creating it if needed) once, then
493 // route it like any other note via OpenPath so it works from every
494 // screen — the current screen opens it inline or the loop switches
495 // to the editor.
496 if let Some(vault) = app.vault.clone()
497 && let Ok((details, _, created)) = vault.journal_entry().await
498 {
499 // Notify the current screen's sidebar when freshly created, then
500 // open it — works from every screen via OpenPath.
501 tx.announce_and_open(details.path, created);
502 }
503 }
504 AppEvent::PreferencesSaved | AppEvent::OnboardingFinished => {
505 // Rebuild the vault so workspace path and inbox_path changes take effect.
506 app.vault = App::open_vault(&app.settings).await;
507 respawn_rag(app, tx);
508 tx.send(AppEvent::OpenScreen(ScreenEvent::Start)).ok();
509 }
510 AppEvent::ClosePreferences => {
511 tx.send(AppEvent::OpenScreen(ScreenEvent::Start)).ok();
512 }
513 AppEvent::VaultConflict(msg) => {
514 // The vault has structural conflicts (e.g. case-insensitive path clashes).
515 // Clear the workspace so the user is not stuck in a loop, then show
516 // the settings screen with the error overlay pre-populated.
517 {
518 let mut s = app.settings.write().unwrap();
519 s.clear_workspace();
520 s.save_to_disk().ok();
521 }
522 app.vault = None;
523 respawn_rag(app, tx);
524 switch_screen(app, tx, ScreenEvent::OpenPreferencesWithError(msg)).await;
525 }
526 AppEvent::WorkspaceSwitched(name) => {
527 {
528 let mut s = app.settings.write().unwrap();
529 if let Some(ref mut wc) = s.workspace_config {
530 wc.global.current_workspace = name;
531 }
532 s.save_to_disk().ok();
533 }
534 app.vault = App::open_vault(&app.settings).await;
535 respawn_rag(app, tx);
536 tx.send(AppEvent::OpenScreen(ScreenEvent::Start)).ok();
537 }
538 AppEvent::Update(flow) => {
539 // The app-global half of the update lifecycle: remember the
540 // notice (so a later-opened editor is seeded in switch_screen),
541 // persist a dismissal, clear on dismiss/install. Every flow
542 // event is then forwarded — the editor screen owns the display
543 // half (indicator, dialog, running the install).
544 match &flow {
545 UpdateFlow::Available(status) => app.update = Some(status.clone()),
546 UpdateFlow::Dismiss(version) => {
547 if let Ok(config_dir) = crate::settings::config_dir()
548 && let Err(e) = crate::update::dismiss(&config_dir, version)
549 {
550 tracing::debug!("could not persist update dismissal: {e}");
551 }
552 app.update = None;
553 }
554 UpdateFlow::Applied => app.update = None,
555 UpdateFlow::Apply | UpdateFlow::ShowDialog => {}
556 }
557 app.current_screen
558 .handle_app_message(AppEvent::Update(flow), tx)
559 .await;
560 }
561 AppEvent::RagStatus(status) => {
562 // Same pattern as update: keep app-globally for screen seeding, and
563 // forward for immediate display.
564 app.rag_status = status;
565 app.current_screen
566 .handle_app_message(AppEvent::RagStatus(status), tx)
567 .await;
568 }
569 other => {
570 app.current_screen.handle_app_message(other, tx).await;
571 }
572 }
573 Ok(())
574}
575
576#[cfg(test)]
577mod tests {
578 use std::sync::{Arc, RwLock};
579
580 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
581
582 use super::App;
583 use crate::app_screen::ScreenKind;
584 use crate::keys::action_shortcuts::ActionShortcuts;
585 use crate::keys::key_event_to_combo;
586 use crate::settings::AppSettings;
587
588 #[tokio::test]
589 async fn from_settings_without_a_workspace_starts_on_the_start_screen_with_no_vault() {
590 let settings = Arc::new(RwLock::new(AppSettings::default()));
591 let app = App::from_settings(settings).await;
592 assert!(app.vault.is_none());
593 assert_eq!(app.current_screen.get_kind(), ScreenKind::Start);
594 assert_eq!(app.screen_generation, 0);
595 }
596
597 /// Ctrl+, is the global shortcut for OpenPreferences, handled in `run_app`
598 /// before any screen.
599 #[test]
600 fn settings_keybinding_sends_open_settings() {
601 let settings = AppSettings::default();
602 let key = KeyEvent {
603 code: KeyCode::Char(','),
604 modifiers: KeyModifiers::CONTROL,
605 kind: KeyEventKind::Press,
606 state: KeyEventState::NONE,
607 };
608
609 let combo = key_event_to_combo(&key).expect("Ctrl+, should produce a combo");
610 let action = settings.key_bindings.get_action(&combo);
611 assert_eq!(action, Some(ActionShortcuts::OpenPreferences));
612 }
613}