Skip to main content

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}