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//! `ctrl_h` (which of two keys a `0x08` byte is).
6
7pub(crate) mod bootstrap;
8pub mod ctrl_h;
9pub mod events;
10pub(crate) mod terminal;
11
12use std::io;
13use std::process::ExitCode;
14use std::sync::{Arc, RwLock};
15
16use color_eyre::eyre;
17use kimun_core::{NoteVault, VaultConfig};
18use ratatui::Terminal;
19use ratatui::prelude::Backend;
20
21use crate::app_screen::browse::BrowseScreen;
22use crate::app_screen::editor::EditorScreen;
23use crate::app_screen::onboarding::OnboardingScreen;
24use crate::app_screen::preferences::PreferencesScreen;
25use crate::app_screen::start::StartScreen;
26use crate::app_screen::{AppScreen, ScreenKind};
27use crate::components::events::{
28    AppEvent, AppTx, AppTxExt, FileOp, InputEvent, ScreenEvent, UpdateFlow,
29};
30use crate::keys::action_shortcuts::ActionShortcuts;
31use crate::keys::key_event_to_combo;
32use crate::settings::{AppSettings, SharedSettings};
33use clap::Parser;
34use color_eyre::Result;
35use events::EventHandler;
36use std::path::PathBuf;
37
38#[derive(Parser)]
39#[command(name = "kimun", about = "Kimün notes", version)]
40pub struct Cli {
41    /// Path to a custom config file
42    #[arg(long, value_name = "FILE")]
43    pub config: Option<PathBuf>,
44
45    #[command(subcommand)]
46    pub command: Option<crate::cli::CliCommand>,
47}
48
49/// The process entry the binary delegates to. Owns the runtime: the nvim
50/// backend uses `tokio::task::block_in_place` during construction, which
51/// requires the multi-thread flavor — that constraint lives here, next to the
52/// code that has it, not in the shim.
53pub fn main() -> Result<ExitCode> {
54    color_eyre::install()?;
55    let runtime = tokio::runtime::Builder::new_multi_thread()
56        .enable_all()
57        .build()?;
58    runtime.block_on(entry())
59}
60
61async fn entry() -> Result<ExitCode> {
62    // Computed once, reused by logging and the panic hook. The guard is held
63    // to the end of this function, and every exit path returns through here,
64    // so the log is always flushed.
65    let log_dir: PathBuf = kimun_core::system::log_dir().into_path_buf();
66    let _guard = bootstrap::init_logging(&log_dir);
67    bootstrap::install_panic_hook(log_dir.join("kimun.log"));
68
69    let cli = Cli::parse();
70    match cli.command {
71        Some(command) => run_cli_command(command, cli.config).await,
72        None => {
73            run_tui(cli.config).await?;
74            Ok(ExitCode::SUCCESS)
75        }
76    }
77}
78
79/// A user error (missing/existing note, bad input) prints a clean message and
80/// exits with code 2 — distinct from an internal failure, which keeps the full
81/// color_eyre report (exit 1). The recoverable/internal split is core's
82/// `VaultError::user_message` (plus the CLI's own [`crate::cli::UserError`]
83/// for requests core has no error for); the boundary lives here so every CLI
84/// command propagates the typed error (via `?`) and renders identically.
85async fn run_cli_command(
86    command: crate::cli::CliCommand,
87    config: Option<PathBuf>,
88) -> Result<ExitCode> {
89    match crate::cli::run_cli(command, config).await {
90        Ok(()) => Ok(ExitCode::SUCCESS),
91        Err(report) => {
92            let user_message = report
93                .downcast_ref::<kimun_core::error::VaultError>()
94                .and_then(|ve| ve.user_message())
95                .or_else(|| {
96                    report
97                        .downcast_ref::<crate::cli::UserError>()
98                        .map(ToString::to_string)
99                });
100            if let Some(msg) = user_message {
101                // Returned, not `process::exit`ed: the code unwinds back
102                // through `entry`, so the runtime and the log guard are
103                // dropped normally on the way out.
104                eprintln!("Error: {msg}");
105                return Ok(ExitCode::from(2));
106            }
107            Err(report)
108        }
109    }
110}
111
112/// The TUI: settings and vault first (a startup error prints to a normal
113/// terminal), then the **Terminal session**, the background tasks, the loop.
114/// The session is left before anything else is printed.
115pub async fn run_tui(config_path: Option<PathBuf>) -> Result<()> {
116    let mut app = App::new(config_path).await?;
117    // Read after App::new since both live in its settings (ADR-0015).
118    let (mouse_capture, ctrl_h_setting) = {
119        let s = app.settings.read().unwrap();
120        (s.mouse(), s.ctrl_h)
121    };
122    let mut session = terminal::TerminalSession::enter(mouse_capture)?;
123    // Resolved after `enter`: whether the kitty flags went out is half of the
124    // answer, and only the live session knows it.
125    let ctrl_h = ctrl_h::CtrlHPolicy::resolve(ctrl_h_setting, session.keyboard_enhanced());
126    let mut events = EventHandler::new(ctrl_h);
127    app.parked_flashes.extend(unreachable_binding_notice(
128        &app,
129        &session,
130        ctrl_h_setting,
131        ctrl_h,
132    ));
133
134    spawn_update_check(&app, events.app_sender());
135    respawn_rag(&mut app, &events.app_sender());
136
137    let outcome = run_app(session.terminal_mut(), &mut app, &mut events).await;
138    drop(session);
139
140    if let Err(e) = outcome {
141        tracing::error!("fatal error: {e}");
142        return Err(e.into());
143    }
144    crate::components::text_editor::widener_metrics::dump_if_enabled();
145    Ok(())
146}
147
148pub struct App {
149    /// The live **Screen**. There is always exactly one: the app starts on
150    /// **Start** and every transition swaps the box whole in `switch_screen`,
151    /// so there is no in-between state to represent.
152    pub current_screen: Box<dyn AppScreen>,
153
154    pub settings: SharedSettings,
155
156    /// The active vault. `None` until a workspace path is configured.
157    /// Rebuilt only when the workspace path changes in settings.
158    pub vault: Option<Arc<NoteVault>>,
159
160    /// Monotonic counter bumped by every screen swap (see `switch_screen`
161    /// below). The main event loop breaks its inner drain when this changes,
162    /// so the new screen is drawn before any event still queued is delivered
163    /// to it. The queued events are not dropped — filtering stale results is
164    /// the job of the addressed event families (`OverlayData`, `Ask`) and
165    /// per-event guards, not of this counter.
166    pub screen_generation: u64,
167
168    /// A newer release found by the background update check at startup, if any.
169    /// Seeded into each editor screen so the footer can show the indicator.
170    pub update: Option<crate::update::UpdateStatus>,
171
172    /// One-shot flashes waiting for a screen that can show them, oldest
173    /// first: the notice that some key binding cannot reach this terminal,
174    /// and whatever [`AppEvent::ParkFlash`] sent (Start's "your last note is
175    /// gone").
176    ///
177    /// Parked rather than sent, because they are decided while **Start** is
178    /// the live screen, and `FlashMessage` is only handled by the editor.
179    /// `switch_screen` drains them the first time it opens an editor, so each
180    /// is shown once and not re-flashed on every later screen swap (unlike
181    /// `update`, which is a standing indicator and is re-seeded).
182    pub parked_flashes: Vec<String>,
183
184    /// The background RAG sync task for the current vault, when a server is
185    /// configured. Aborted and respawned when the vault is rebuilt.
186    pub rag_sync_task: Option<tokio::task::JoinHandle<()>>,
187
188    /// Latest RAG status from the background task, held app-globally so a
189    /// freshly-opened editor can be seeded immediately (like `update`) instead
190    /// of showing nothing until the next sync tick.
191    pub rag_status: crate::rag::RagStatus,
192    /// Newer server release reported by the server, if any. Seeded into each
193    /// screen alongside `rag_status`.
194    pub server_update: Option<crate::server_client::sync::ServerUpdate>,
195}
196
197impl App {
198    /// Load settings from `config_path` (or the default location) and build
199    /// the app on them.
200    pub async fn new(config_path: Option<std::path::PathBuf>) -> eyre::Result<Self> {
201        let loaded_settings = match config_path {
202            Some(path) => AppSettings::load_from_file(path)?,
203            None => AppSettings::load_from_disk()?,
204        };
205        Ok(Self::from_settings(Arc::new(RwLock::new(loaded_settings))).await)
206    }
207
208    /// The app over already-loaded settings: opens the vault those settings
209    /// name (if any) and starts on the **Start** screen. The door tests use —
210    /// nothing here touches the config file.
211    pub async fn from_settings(settings: SharedSettings) -> Self {
212        let vault = Self::open_vault(&settings).await;
213        Self {
214            current_screen: Box::new(StartScreen::new(settings.clone(), vault.clone())),
215            settings,
216            vault,
217            screen_generation: 0,
218            update: None,
219            parked_flashes: Vec::new(),
220            rag_sync_task: None,
221            rag_status: crate::rag::RagStatus::Disabled,
222            server_update: None,
223        }
224    }
225
226    /// A fresh `NoteVault` for whatever workspace the settings currently
227    /// resolve to, wired to the configured index and the workspace's inbox.
228    /// `None` if no workspace is configured or the vault fails to open.
229    ///
230    /// Used at startup and every time the workspace changes (preferences
231    /// saved, onboarding finished, workspace switched).
232    pub async fn open_vault(settings: &SharedSettings) -> Option<Arc<NoteVault>> {
233        let (workspace_path, cache_path, inbox_path) = {
234            let s = settings.read().unwrap();
235            let wp = s.resolve_workspace_path();
236            let name = s.current_workspace_name();
237            let cache = name.as_ref().map(|n| s.index_for(n));
238            let ip = s
239                .workspace_config
240                .as_ref()
241                .and_then(|wc| wc.get_current_workspace())
242                .map(|e| e.effective_inbox_path());
243            (wp, cache, ip)
244        };
245        let workspace = workspace_path?;
246        let mut config = VaultConfig::new(workspace.clone());
247        if let Some(cp) = cache_path {
248            config = config.with_index(cp);
249        }
250        match NoteVault::new(config).await {
251            Ok(mut v) => {
252                if let Some(ref ip) = inbox_path {
253                    v.set_inbox_path(kimun_core::nfs::VaultPath::new(ip));
254                }
255                Some(Arc::new(v))
256            }
257            // Don't swallow the cause: since the index self-heal, opening the
258            // vault can fail on a cache probe error (e.g. the cache is locked
259            // by another kimun process). The app falls back to the no-vault
260            // start screen either way, but the reason must reach the log
261            // instead of looking like an unconfigured workspace.
262            Err(e) => {
263                tracing::error!("could not open vault at {}: {e}", workspace);
264                None
265            }
266        }
267    }
268}
269
270/// (Re)starts the background RAG sync for the current vault: aborts any prior
271/// task and spawns a fresh one bound to the current `app.vault`. A no-op sync
272/// (no server configured, or no vault) leaves the task `None`.
273fn respawn_rag(app: &mut App, tx: &crate::components::events::AppTx) {
274    if let Some(handle) = app.rag_sync_task.take() {
275        handle.abort();
276    }
277    if let Some(vault) = app.vault.clone() {
278        app.rag_sync_task = crate::rag::spawn_rag_sync(vault, &app.settings, tx.clone());
279    }
280}
281
282async fn switch_screen(app: &mut App, tx: &AppTx, new_screen: ScreenEvent) {
283    app.current_screen.on_exit(tx).await;
284
285    // Decided before `new_screen` is consumed below. Only the editor handles
286    // `FlashMessage`; every other screen drops it on the floor.
287    let shows_flashes = matches!(new_screen, ScreenEvent::OpenEditor(..));
288
289    let mut screen: Box<dyn AppScreen> = match new_screen {
290        ScreenEvent::Start => Box::new(StartScreen::new(app.settings.clone(), app.vault.clone())),
291        ScreenEvent::OpenPreferences => Box::new(PreferencesScreen::new(app.settings.clone())),
292        ScreenEvent::OpenPreferencesWithError(msg) => {
293            Box::new(PreferencesScreen::new_with_error(app.settings.clone(), msg))
294        }
295        ScreenEvent::OpenEditor(note_vault, vault_path) => Box::new(EditorScreen::new(
296            note_vault,
297            vault_path,
298            app.settings.clone(),
299        )),
300        ScreenEvent::OpenBrowse(note_vault, vault_path) => Box::new(BrowseScreen::new(
301            note_vault,
302            vault_path,
303            app.settings.clone(),
304        )),
305        ScreenEvent::OpenOnboarding => Box::new(OnboardingScreen::new(app.settings.clone())),
306    };
307
308    screen.on_enter(tx).await;
309    // Seed the freshly-created screen with any pending update notice, so the
310    // editor footer shows it even though the check finished before this screen
311    // existed. Non-editor screens ignore the event.
312    if let Some(status) = app.update.clone() {
313        screen
314            .handle_app_message(AppEvent::Update(UpdateFlow::Available(status)), tx)
315            .await;
316    }
317    // Drained, not cloned: a flash is a one-shot, and re-firing it on every
318    // screen swap would turn a warning into a nag. Drained only for a screen
319    // that shows flashes — Start can route through Onboarding or Browse
320    // first, and handing the notice to one of those loses it for good.
321    // Joined into one flash: the footer holds a single one, so delivering
322    // them separately would leave only the last on screen.
323    if shows_flashes && !app.parked_flashes.is_empty() {
324        let msg = std::mem::take(&mut app.parked_flashes).join(" · ");
325        screen
326            .handle_app_message(AppEvent::FlashMessage(msg), tx)
327            .await;
328    }
329    screen
330        .handle_app_message(AppEvent::RagStatus(app.rag_status), tx)
331        .await;
332    screen
333        .handle_app_message(AppEvent::ServerUpdate(app.server_update.clone()), tx)
334        .await;
335    app.current_screen = screen;
336    // Bumped here (not at every swap site) because every swap goes through
337    // this function. The main loop watches this counter to break its inner
338    // event drain whenever the screen identity changes, so the new screen is
339    // drawn before any event still queued is delivered to it.
340    app.screen_generation = app.screen_generation.wrapping_add(1);
341}
342
343/// Tell the user about a binding their terminal cannot deliver.
344///
345/// Their own, with one exception. The default keymap always keeps a chord
346/// that survives the weakest terminal kimün supports — `settings`' invariant
347/// test holds it to that — so anything found here came out of a
348/// `[key_bindings]` section and is theirs to change. The exception is the
349/// Ctrl-H rewrite: under a Backspace policy `FocusSidebar` loses its only
350/// default chord, and the notice then names `ctrl_h` rather than the
351/// terminal (see `notice_text`). That is the whole reason this reports rather
352/// than silently repairing: rebinding under them is exactly the surprise
353/// moving formatting to the leader was meant to avoid.
354///
355/// Returns the notice rather than sending it: at this point **Start** is the
356/// live screen and only the editor handles `FlashMessage`, so it is parked on
357/// [`App::parked_flashes`] for `switch_screen` to deliver once an editor is
358/// opened.
359///
360/// Rare in practice, and deliberately so: `merge_missing_default_bindings`
361/// hands an action its default combo back unless the config gave that combo to
362/// something else. So `SearchNotes = ["ctrl&I"]` alone still answers to Ctrl-K
363/// and stays reachable; it takes a config that *also* claims Ctrl-K elsewhere
364/// to strand it. Quiet is the point — a warning that fired on a working setup
365/// would teach people to ignore it.
366///
367/// Logged as a warning (durable — the troubleshooting docs send people to the
368/// log) and flashed once in the footer (visible, and gone in two seconds
369/// rather than nagging). `kimun doctor` prints the full picture on demand.
370fn unreachable_binding_notice(
371    app: &App,
372    session: &terminal::TerminalSession,
373    setting: crate::settings::CtrlHSetting,
374    ctrl_h: ctrl_h::CtrlHPolicy,
375) -> Option<String> {
376    use crate::keys::reachability::{TerminalKeys, unreachable_actions};
377
378    let keys = TerminalKeys::detected(
379        session.keyboard_enhanced(),
380        // `FocusSidebar`'s only default chord is `Ctrl+H`, so under the
381        // rewrite it is stranded. The rule for when that is worth saying
382        // lives with the policy.
383        //
384        // Deliberately not `policy == CtrlHPolicy::Backspace` — what
385        // `doctor.rs` passes for this same argument. Under an explicit
386        // `ctrl_h = "backspace"` `loss_is_worth_reporting` is false, so this
387        // startup scan reports Ctrl+H as reachable while `kimun doctor`
388        // still reports it stranded: the flash is for surprises, quiet on a
389        // setting the user picked on purpose, while doctor always shows the
390        // full picture on request.
391        ctrl_h.loss_is_worth_reporting(setting),
392    );
393    let stranded = {
394        let settings = app.settings.read().unwrap();
395        unreachable_actions(&settings.key_bindings, keys)
396    };
397    if stranded.is_empty() {
398        return None;
399    }
400    for u in &stranded {
401        for (combo, fate) in &u.combos {
402            tracing::warn!("key binding {combo} for {}: {fate}", u.action);
403        }
404    }
405    Some(notice_text(&stranded, setting, ctrl_h))
406}
407
408/// The footer renders this flash as a single unwrapped, centre-aligned
409/// `Paragraph` (`components::footer_bar.rs`). Ratatui's centre offset is
410/// `(area/2).saturating_sub(line/2)`, which is `0` once the line is wider
411/// than the area — so a line past this many columns on an 80-column footer
412/// renders from the left and the TAIL is what gets clipped. Kept as a
413/// constant (rather than inlined into the test) so the budget has one place
414/// to change and a comment explaining why it exists.
415///
416/// Only asserted by `the_single_clause_notice_fits_the_flash_width_budget`
417/// below, not consulted by `notice_text` itself: the two-clause case reports
418/// two independent facts and cannot always fit it, so there is no single
419/// formatting rule this constant could gate at runtime.
420#[allow(
421    dead_code,
422    reason = "read by the width-budget test, not by notice_text"
423)]
424const FLASH_WIDTH_BUDGET: usize = 72;
425
426/// More than this many actions in one clause are named, then folded into an
427/// "and N more" tail — the flash has to stay on `FLASH_WIDTH_BUDGET`, not
428/// grow with however many bindings a user managed to strand.
429const FLASH_NAME_LIMIT: usize = 2;
430
431/// The footer line for a stranded-binding scan.
432///
433/// Partitioned by *cause*, not by policy. Under a Backspace policy the scan
434/// can turn up two different kinds of entry in the same call: the bare
435/// `Ctrl+H` chord that the rewrite itself shadowed with Backspace — kimün's
436/// own doing, `auto` read the tty's erase character or the user set
437/// `backspace` — and, independently, anything else the user's own
438/// `[key_bindings]` bound to a chord this terminal cannot deliver (a
439/// `SearchNotes = ["ctrl&I"]` colliding with Tab is always-on and has nothing
440/// to do with `ctrl_h`). Wrapping *that* action's name in "Ctrl+H is
441/// Backspace" would send the user off to change the wrong setting, so only
442/// the entries the rewrite actually caused get the ctrl_h wording; everything
443/// else keeps the plain "no usable key" line. Both clauses appear,
444/// semicolon-joined, when one scan turns up both causes.
445///
446/// Deliberately terse: this is a flash, not the report. It names the action,
447/// the `ctrl_h` setting responsible (when that is the cause), and points at
448/// `kimun doctor` — `stranded_advice` there is what actually says "rebind or
449/// set ctrl_h", in more words than a footer line can afford. Kept at or under
450/// [`FLASH_WIDTH_BUDGET`] for the common single-clause case so the pointer to
451/// `kimun doctor` is never the part that gets clipped.
452fn notice_text(
453    stranded: &[crate::keys::reachability::Unreachable],
454    setting: crate::settings::CtrlHSetting,
455    policy: ctrl_h::CtrlHPolicy,
456) -> String {
457    use crate::keys::key_combo::{KeyCombo, KeyModifiers};
458    use crate::keys::key_strike::KeyStrike;
459    use crate::keys::reachability::Reach;
460
461    // The exact pair the rewrite itself produces: `reach()` shadows the bare
462    // chord with the plain key, nothing else. Checking the pair (not just the
463    // combo) is what keeps a hand-built entry that merely mentions Ctrl+H
464    // under some other reach — or a real collision that happens to share the
465    // combo — out of this group.
466    let ctrl_h_combo = KeyCombo::new(KeyModifiers::new().and_ctrl(), KeyStrike::KeyH);
467    let backspace_key = KeyCombo::new(KeyModifiers::new(), KeyStrike::Backspace);
468    let is_ctrl_h_loss = |u: &&crate::keys::reachability::Unreachable| {
469        policy == ctrl_h::CtrlHPolicy::Backspace
470            && u.combos.iter().any(|(combo, reach)| {
471                *combo == ctrl_h_combo && *reach == Reach::Shadowed(backspace_key)
472            })
473    };
474    let (ctrl_h_group, plain_group): (Vec<_>, Vec<_>) = stranded.iter().partition(is_ctrl_h_loss);
475
476    // Names two actions at most; anything past that is folded into a count so
477    // a heavily-remapped keymap cannot blow the width budget.
478    let join_names = |group: &[&crate::keys::reachability::Unreachable]| {
479        let names: Vec<String> = group.iter().map(|u| u.action.to_string()).collect();
480        if names.len() <= FLASH_NAME_LIMIT {
481            names.join(", ")
482        } else {
483            format!(
484                "{} and {} more",
485                names[..FLASH_NAME_LIMIT].join(", "),
486                names.len() - FLASH_NAME_LIMIT
487            )
488        }
489    };
490
491    let ctrl_h_clause = (!ctrl_h_group.is_empty()).then(|| {
492        let names = join_names(&ctrl_h_group);
493        let setting = format!("{setting:?}").to_lowercase();
494        format!("{names}: Ctrl+H is Backspace (ctrl_h = \"{setting}\")")
495    });
496    let plain_clause = (!plain_group.is_empty())
497        .then(|| format!("{}: no usable key here", join_names(&plain_group)));
498
499    match (ctrl_h_clause, plain_clause) {
500        (Some(a), None) => format!("{a} — run `kimun doctor`"),
501        (None, Some(b)) => format!("{b} — run `kimun doctor`"),
502        (Some(a), Some(b)) => format!("{a}; {b} — run `kimun doctor`"),
503        (None, None) => {
504            unreachable!("unreachable_binding_notice never calls this with an empty scan")
505        }
506    }
507}
508
509/// Kick off the background update check (gated on the user's `update_check`
510/// preference). All network/filesystem work runs on `spawn_blocking` inside
511/// `update::check_now`; a found update is surfaced via `AppEvent::Update(UpdateFlow::Available)`. Failures are logged and
512/// swallowed — the check never blocks startup or interaction.
513fn spawn_update_check(app: &App, tx: AppTx) {
514    if !app.settings.read().unwrap().update_check() {
515        return;
516    }
517    let Ok(config_dir) = crate::settings::config_dir() else {
518        return;
519    };
520    tokio::spawn(async move {
521        match crate::update::check_now(config_dir, false).await {
522            Ok(Some(status)) if status.should_notify() => {
523                let _ = tx.send(AppEvent::Update(UpdateFlow::Available(status)));
524            }
525            Ok(_) => {}
526            Err(e) => tracing::debug!("update check failed: {e}"),
527        }
528    });
529}
530
531/// The **App loop** (CONTEXT.md § App shell).
532///
533/// Draw the screen, wait for the next event, drain what is already queued,
534/// draw again. The rules that live here and nowhere else:
535///
536/// - Global shortcuts (`Quit`, `OpenPreferences`) fire before the screen
537///   sees the key.
538/// - Queued app messages are coalesced: one frame per batch. A real input
539///   event always comes through `next()` and so always gets its own draw.
540/// - A screen swap mid-drain ends the drain (`App::screen_generation`), so the
541///   new screen is drawn before any event still queued is delivered to it.
542///   The queued events are *not* dropped — they are read again on the next
543///   iteration and reach the new screen. Filtering stale results is the job
544///   of the addressed event families (`OverlayData`, `Ask`) and per-event
545///   guards, not of this loop.
546/// - `Quit` runs the current screen's `on_exit` before returning.
547///
548/// Generic over the terminal backend and fed by an **Input source**, so it
549/// runs headless in tests (`tests/app_loop_test.rs`).
550pub async fn run_app<B: Backend>(
551    terminal: &mut Terminal<B>,
552    app: &mut App,
553    events: &mut EventHandler,
554) -> io::Result<()>
555where
556    B::Error: std::error::Error + Send + Sync + 'static,
557{
558    let tx = events.app_sender();
559
560    app.current_screen.on_enter(&tx).await;
561
562    loop {
563        terminal
564            .draw(|f| app.current_screen.render(f))
565            // A `From` bound into `io::Error` would exclude `TestBackend`
566            // (`Error = Infallible`), which the headless loop tests use.
567            // Wrapping through `io::Error::other` keeps the source error and
568            // needs only the standard error bounds.
569            .map_err(io::Error::other)?;
570
571        // Block until at least one event arrives, then drain everything else
572        // that is already queued before drawing again. `Redraw` events are
573        // coalesced — the top-of-loop draw paints one frame for the whole
574        // batch instead of one frame per pending message. Crossterm input
575        // events never come through the mpsc channel, so a real key event
576        // always forces a fresh `events.next().await` (and therefore a
577        // dedicated draw) on the next iteration.
578        let mut event = events.next().await;
579        loop {
580            match event {
581                AppEvent::Quit => {
582                    app.current_screen.on_exit(&tx).await;
583                    return Ok(());
584                }
585                AppEvent::Redraw => {
586                    // No-op: top-of-loop draw already happened (or is about to).
587                }
588                AppEvent::Input(input) => {
589                    match input {
590                        InputEvent::Key(key) => {
591                            tracing::debug!(
592                                "KEY: code={:?} mods={:?} kind={:?}",
593                                key.code,
594                                key.modifiers,
595                                key.kind
596                            );
597                            // Global shortcuts — fire before any screen gets the event.
598                            if let Some(combo) = key_event_to_combo(&key) {
599                                let action = {
600                                    let s = app.settings.read().unwrap();
601                                    tracing::debug!(
602                                        "COMBO: {} → {:?}",
603                                        combo,
604                                        s.key_bindings.get_action(&combo)
605                                    );
606                                    s.key_bindings.get_action(&combo)
607                                };
608                                let handled_global = match action {
609                                    Some(ActionShortcuts::Quit) => {
610                                        tx.send(AppEvent::Quit).ok();
611                                        true
612                                    }
613                                    Some(ActionShortcuts::OpenPreferences) => {
614                                        let already_on_settings = app.current_screen.get_kind()
615                                            == ScreenKind::Preferences;
616                                        if !already_on_settings {
617                                            tx.send(AppEvent::OpenScreen(
618                                                ScreenEvent::OpenPreferences,
619                                            ))
620                                            .ok();
621                                        }
622                                        true
623                                    }
624                                    _ => false,
625                                };
626                                if handled_global {
627                                    // Skip screen-level handling for this key.
628                                    match events.try_next() {
629                                        Some(next) => {
630                                            event = next;
631                                            continue;
632                                        }
633                                        None => break,
634                                    }
635                                }
636                            }
637                            app.current_screen.handle_input(&InputEvent::Key(key), &tx);
638                        }
639                        InputEvent::Mouse(mouse_event) => {
640                            app.current_screen
641                                .handle_input(&InputEvent::Mouse(mouse_event), &tx);
642                        }
643                        InputEvent::Paste(text) => {
644                            app.current_screen
645                                .handle_input(&InputEvent::Paste(text), &tx);
646                        }
647                    }
648                }
649                msg => {
650                    // Capture screen identity around handle_app_message so we
651                    // can detect a synchronous screen swap (OpenScreen,
652                    // VaultConflict). Use `screen_generation` rather than
653                    // `ScreenKind`, because a swap between two screens of the
654                    // same kind (e.g. EditorScreen(A) → follow-link →
655                    // EditorScreen(B)) still leaks A's queued events into B
656                    // if we only compare kinds. Remaining queued events
657                    // belong to the OLD screen instance — break the drain so
658                    // they get a fresh outer iteration where they are routed
659                    // correctly (and the new screen gets its first draw
660                    // before further input).
661                    let before_gen = app.screen_generation;
662                    handle_app_message(msg, app, &tx).await?;
663                    if app.screen_generation != before_gen {
664                        break;
665                    }
666                }
667            }
668            match events.try_next() {
669                Some(next) => event = next,
670                None => break,
671            }
672        }
673    }
674}
675
676async fn handle_app_message(msg: AppEvent, app: &mut App, tx: &AppTx) -> io::Result<()> {
677    match msg {
678        AppEvent::Redraw => {}
679        AppEvent::OpenScreen(screen) => {
680            switch_screen(app, tx, screen).await;
681        }
682        AppEvent::ParkFlash(msg) => app.parked_flashes.push(msg),
683        AppEvent::OpenPath { path, emphasis } => {
684            // We either handle the new path within the current screen, or we switch to a new screen for this path
685            let unhandled = app.current_screen.try_open_path(path, emphasis, tx).await;
686            if let Some(path) = unhandled {
687                if let Some(vault) = app.vault.clone() {
688                    if path.is_note() {
689                        tx.send(AppEvent::OpenScreen(ScreenEvent::OpenEditor(vault, path)))
690                            .ok();
691                    } else {
692                        tx.send(AppEvent::OpenScreen(ScreenEvent::OpenBrowse(vault, path)))
693                            .ok();
694                    }
695                } else {
696                    // No vault → the app is unconfigured. Route to the guided
697                    // setup, not Preferences (onboarding replaces the
698                    // preferences fallthrough as the no-workspace path).
699                    tx.send(AppEvent::OpenScreen(ScreenEvent::OpenOnboarding))
700                        .ok();
701                }
702            }
703        }
704        AppEvent::OpenAttachment(path) => {
705            // The editor screen shows it in its attachment view; any other
706            // screen routes through OpenEditor first, then the attachment opens
707            // there. (In practice this is sent from the editor's FILES drawer.)
708            let unhandled = app.current_screen.try_open_attachment(path, tx).await;
709            if let Some(path) = unhandled
710                && let Some(vault) = app.vault.clone()
711            {
712                tx.send(AppEvent::OpenScreen(ScreenEvent::OpenEditor(vault, path)))
713                    .ok();
714            }
715        }
716        AppEvent::OpenJournal => {
717            // Resolve today's journal entry (creating it if needed) once, then
718            // route it like any other note via OpenPath so it works from every
719            // screen — the current screen opens it inline or the loop switches
720            // to the editor.
721            if let Some(vault) = app.vault.clone()
722                && let Ok((details, _, created)) = vault.journal_entry().await
723            {
724                // Notify the current screen's sidebar when freshly created, then
725                // open it — works from every screen via OpenPath.
726                tx.announce_and_open(details.path, created);
727            }
728        }
729        AppEvent::PreferencesSaved | AppEvent::OnboardingFinished => {
730            // Rebuild the vault so workspace path and inbox_path changes take effect.
731            app.vault = App::open_vault(&app.settings).await;
732            respawn_rag(app, tx);
733            tx.send(AppEvent::OpenScreen(ScreenEvent::Start)).ok();
734        }
735        AppEvent::ClosePreferences => {
736            tx.send(AppEvent::OpenScreen(ScreenEvent::Start)).ok();
737        }
738        AppEvent::VaultConflict(msg) => {
739            // The vault has structural conflicts (e.g. case-insensitive path clashes).
740            // Clear the workspace so the user is not stuck in a loop, then show
741            // the settings screen with the error overlay pre-populated.
742            {
743                let mut s = app.settings.write().unwrap();
744                s.clear_workspace();
745                s.save_to_disk().ok();
746            }
747            app.vault = None;
748            respawn_rag(app, tx);
749            switch_screen(app, tx, ScreenEvent::OpenPreferencesWithError(msg)).await;
750        }
751        AppEvent::WorkspaceSwitched(name) => {
752            {
753                let mut s = app.settings.write().unwrap();
754                if let Some(ref mut wc) = s.workspace_config {
755                    wc.global.current_workspace = name;
756                }
757                s.save_to_disk().ok();
758            }
759            app.vault = App::open_vault(&app.settings).await;
760            respawn_rag(app, tx);
761            tx.send(AppEvent::OpenScreen(ScreenEvent::Start)).ok();
762        }
763        AppEvent::Update(flow) => {
764            // The app-global half of the update lifecycle: remember the
765            // notice (so a later-opened editor is seeded in switch_screen),
766            // persist a dismissal, clear on dismiss/install. Every flow
767            // event is then forwarded — the editor screen owns the display
768            // half (indicator, dialog, running the install).
769            match &flow {
770                UpdateFlow::Available(status) => app.update = Some(status.clone()),
771                UpdateFlow::Dismiss(version) => {
772                    if let Ok(config_dir) = crate::settings::config_dir()
773                        && let Err(e) = crate::update::dismiss(&config_dir, version)
774                    {
775                        tracing::debug!("could not persist update dismissal: {e}");
776                    }
777                    app.update = None;
778                }
779                UpdateFlow::Applied => app.update = None,
780                UpdateFlow::Apply | UpdateFlow::ShowDialog => {}
781            }
782            app.current_screen
783                .handle_app_message(AppEvent::Update(flow), tx)
784                .await;
785        }
786        AppEvent::RagStatus(status) => {
787            // Same pattern as update: keep app-globally for screen seeding, and
788            // forward for immediate display.
789            app.rag_status = status;
790            app.current_screen
791                .handle_app_message(AppEvent::RagStatus(status), tx)
792                .await;
793        }
794        AppEvent::ServerUpdate(latest) => {
795            app.server_update = latest.clone();
796            app.current_screen
797                .handle_app_message(AppEvent::ServerUpdate(latest), tx)
798                .await;
799        }
800        AppEvent::FileOp(op) => {
801            // History is app-wide bookkeeping; keep it in step here, whichever
802            // screen hosted the operation, then let the screen react.
803            match &op {
804                FileOp::Renamed { from, to } | FileOp::Moved { from, to } => {
805                    app.settings
806                        .read()
807                        .unwrap()
808                        .follow_rename_in_history(from, to);
809                }
810                FileOp::Deleted(path) => {
811                    app.settings.read().unwrap().follow_delete_in_history(path);
812                }
813                _ => {}
814            }
815            app.current_screen
816                .handle_app_message(AppEvent::FileOp(op), tx)
817                .await;
818        }
819        other => {
820            app.current_screen.handle_app_message(other, tx).await;
821        }
822    }
823    Ok(())
824}
825
826#[cfg(test)]
827mod tests {
828    use std::sync::{Arc, RwLock};
829
830    use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
831
832    use super::App;
833    use crate::app_screen::ScreenKind;
834    use crate::keys::action_shortcuts::ActionShortcuts;
835    use crate::keys::key_event_to_combo;
836    use crate::settings::AppSettings;
837
838    #[tokio::test]
839    async fn from_settings_without_a_workspace_starts_on_the_start_screen_with_no_vault() {
840        let settings = Arc::new(RwLock::new(AppSettings::default()));
841        let app = App::from_settings(settings).await;
842        assert!(app.vault.is_none());
843        assert_eq!(app.current_screen.get_kind(), ScreenKind::Start);
844        assert_eq!(app.screen_generation, 0);
845    }
846
847    /// Ctrl+, is the global shortcut for OpenPreferences, handled in `run_app`
848    /// before any screen.
849    #[test]
850    fn settings_keybinding_sends_open_settings() {
851        let settings = AppSettings::default();
852        let key = KeyEvent {
853            code: KeyCode::Char(','),
854            modifiers: KeyModifiers::CONTROL,
855            kind: KeyEventKind::Press,
856            state: KeyEventState::NONE,
857        };
858
859        let combo = key_event_to_combo(&key).expect("Ctrl+, should produce a combo");
860        let action = settings.key_bindings.get_action(&combo);
861        assert_eq!(action, Some(ActionShortcuts::OpenPreferences));
862    }
863
864    /// The startup key warning is parked until a screen that can show it.
865    /// Start can route to Onboarding or Browse before any editor exists;
866    /// consuming the flash there loses the only notice the user gets.
867    #[tokio::test]
868    async fn the_key_warning_waits_for_a_screen_that_shows_flashes() {
869        use crate::components::events::ScreenEvent;
870        use kimun_core::nfs::VaultPath;
871        use kimun_core::{NoteVault, VaultConfig};
872
873        let settings = Arc::new(RwLock::new(AppSettings::default()));
874        let mut app = App::from_settings(settings).await;
875        app.parked_flashes.push("stranded".to_string());
876        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
877
878        super::switch_screen(&mut app, &tx, ScreenEvent::OpenOnboarding).await;
879        assert_eq!(
880            app.parked_flashes,
881            vec!["stranded".to_string()],
882            "onboarding cannot show a flash, so the warning must still be parked"
883        );
884
885        let dir = tempfile::TempDir::new().unwrap();
886        let vault = Arc::new(
887            NoteVault::new(VaultConfig::new(crate::test_support::sys(dir.path())))
888                .await
889                .unwrap(),
890        );
891        super::switch_screen(
892            &mut app,
893            &tx,
894            ScreenEvent::OpenEditor(vault, VaultPath::root()),
895        )
896        .await;
897        assert!(
898            app.parked_flashes.is_empty(),
899            "the editor shows flashes, so the one-shot is delivered and gone"
900        );
901    }
902
903    /// An app whose current workspace's history is `history`, newest first,
904    /// kept in `scratch` (the config file too).
905    async fn app_with_history(history: &[&str], scratch: &tempfile::TempDir) -> App {
906        let workspace = crate::test_support::sys(scratch.path());
907        let settings = AppSettings::for_test_workspace("ws", &workspace, workspace.clone());
908        settings.seed_test_history(history);
909        App::from_settings(Arc::new(RwLock::new(settings))).await
910    }
911
912    async fn file_op(app: &mut App, op: crate::components::events::FileOp) {
913        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
914        super::handle_app_message(crate::components::events::AppEvent::FileOp(op), app, &tx)
915            .await
916            .unwrap();
917    }
918
919    /// The history as plain strings, form-independent (entries may be stored
920    /// relative or absolute).
921    fn history_of(app: &App) -> Vec<String> {
922        app.settings
923            .read()
924            .unwrap()
925            .current_last_paths()
926            .iter()
927            .map(|p| {
928                let mut p = p.clone();
929                p.to_relative();
930                p.to_string()
931            })
932            .collect()
933    }
934
935    /// A rename done inside kimün must follow through to the history, or the
936    /// next launch finds the old path gone and blames an outside change.
937    #[tokio::test]
938    async fn renaming_a_note_rewrites_it_in_the_history() {
939        use crate::components::events::FileOp;
940        use kimun_core::nfs::VaultPath;
941
942        let scratch = tempfile::TempDir::new().unwrap();
943        let mut app = app_with_history(&["a.md", "other.md"], &scratch).await;
944        file_op(
945            &mut app,
946            FileOp::Renamed {
947                from: VaultPath::new("a.md"),
948                to: VaultPath::new("b.md"),
949            },
950        )
951        .await;
952        assert_eq!(history_of(&app), vec!["b.md", "other.md"]);
953    }
954
955    #[tokio::test]
956    async fn moving_a_directory_rewrites_the_notes_beneath_it() {
957        use crate::components::events::FileOp;
958        use kimun_core::nfs::VaultPath;
959
960        let scratch = tempfile::TempDir::new().unwrap();
961        let mut app = app_with_history(&["dir/a.md", "dirx/b.md"], &scratch).await;
962        file_op(
963            &mut app,
964            FileOp::Moved {
965                from: VaultPath::new("dir"),
966                to: VaultPath::new("archive/dir"),
967            },
968        )
969        .await;
970        assert_eq!(history_of(&app), vec!["archive/dir/a.md", "dirx/b.md"]);
971    }
972
973    #[tokio::test]
974    async fn deleting_drops_the_note_or_everything_beneath_the_directory() {
975        use crate::components::events::FileOp;
976        use kimun_core::nfs::VaultPath;
977
978        let scratch = tempfile::TempDir::new().unwrap();
979        let mut app =
980            app_with_history(&["a.md", "dir/b.md", "dirx/c.md", "keep.md"], &scratch).await;
981        file_op(&mut app, FileOp::Deleted(VaultPath::new("a.md"))).await;
982        file_op(&mut app, FileOp::Deleted(VaultPath::new("dir"))).await;
983        assert_eq!(history_of(&app), vec!["dirx/c.md", "keep.md"]);
984    }
985
986    /// The footer holds one flash at a time, so notices parked together must
987    /// reach it together — delivered one by one, each overwrites the last
988    /// before a frame is ever drawn.
989    #[tokio::test]
990    async fn every_parked_flash_reaches_the_footer() {
991        use crate::components::events::{AppEvent, ScreenEvent};
992        use kimun_core::nfs::VaultPath;
993
994        let settings = Arc::new(RwLock::new(AppSettings::default()));
995        let mut app = App::from_settings(settings).await;
996        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
997        for notice in ["first notice", "second notice"] {
998            super::handle_app_message(AppEvent::ParkFlash(notice.to_string()), &mut app, &tx)
999                .await
1000                .unwrap();
1001        }
1002
1003        let vault = crate::test_support::temp_vault("app").await;
1004        super::switch_screen(
1005            &mut app,
1006            &tx,
1007            ScreenEvent::OpenEditor(vault, VaultPath::root()),
1008        )
1009        .await;
1010
1011        let mut terminal =
1012            ratatui::Terminal::new(ratatui::backend::TestBackend::new(160, 30)).unwrap();
1013        terminal.draw(|f| app.current_screen.render(f)).unwrap();
1014        let flat: String = terminal
1015            .backend()
1016            .buffer()
1017            .content
1018            .iter()
1019            .map(|c| c.symbol())
1020            .collect();
1021        assert!(flat.contains("first notice"), "first notice lost");
1022        assert!(flat.contains("second notice"), "second notice lost");
1023    }
1024
1025    /// When `auto` chose Backspace the stranded action is kimün's own
1026    /// default, and the flash has to say which setting did it — "run doctor"
1027    /// alone reads as a broken install.
1028    #[test]
1029    fn the_notice_names_ctrl_h_when_auto_chose_backspace() {
1030        use crate::app::ctrl_h::CtrlHPolicy;
1031        use crate::keys::key_combo::{KeyCombo, KeyModifiers};
1032        use crate::keys::key_strike::KeyStrike;
1033        use crate::keys::reachability::{Reach, Unreachable};
1034        use crate::settings::CtrlHSetting;
1035
1036        let ctrl_h = KeyCombo::new(KeyModifiers::new().and_ctrl(), KeyStrike::KeyH);
1037        let stranded = vec![Unreachable {
1038            action: ActionShortcuts::FocusSidebar,
1039            combos: vec![(
1040                ctrl_h,
1041                Reach::Shadowed(KeyCombo::new(KeyModifiers::new(), KeyStrike::Backspace)),
1042            )],
1043        }];
1044
1045        let auto = super::notice_text(&stranded, CtrlHSetting::Auto, CtrlHPolicy::Backspace);
1046        assert!(auto.contains("FocusSidebar"), "{auto}");
1047        assert!(auto.contains("ctrl_h"), "{auto}");
1048        assert!(auto.contains("kimun doctor"), "{auto}");
1049
1050        // A stranding the rewrite had no part in keeps the plain wording.
1051        let theirs = super::notice_text(&stranded, CtrlHSetting::Auto, CtrlHPolicy::Chord);
1052        assert!(!theirs.contains("ctrl_h"), "{theirs}");
1053        assert!(theirs.contains("no usable key"), "{theirs}");
1054    }
1055
1056    /// A single scan can turn up both causes at once: the rewrite's own loss
1057    /// and an unrelated collision out of the user's own `[key_bindings]`.
1058    /// Wrapping that second action's name in the ctrl_h wording would send
1059    /// the user off to change the wrong setting, so the two clauses must
1060    /// stay apart in the string, not just both appear somewhere in it.
1061    #[test]
1062    fn the_notice_keeps_an_unrelated_stranding_out_of_the_ctrl_h_clause() {
1063        use crate::app::ctrl_h::CtrlHPolicy;
1064        use crate::keys::key_combo::{KeyCombo, KeyModifiers};
1065        use crate::keys::key_strike::KeyStrike;
1066        use crate::keys::reachability::{Reach, Unreachable};
1067        use crate::settings::CtrlHSetting;
1068
1069        let ctrl_h = KeyCombo::new(KeyModifiers::new().and_ctrl(), KeyStrike::KeyH);
1070        let ctrl_i = KeyCombo::new(KeyModifiers::new().and_ctrl(), KeyStrike::KeyI);
1071        let stranded = vec![
1072            Unreachable {
1073                action: ActionShortcuts::FocusSidebar,
1074                combos: vec![(
1075                    ctrl_h,
1076                    Reach::Shadowed(KeyCombo::new(KeyModifiers::new(), KeyStrike::Backspace)),
1077                )],
1078            },
1079            Unreachable {
1080                action: ActionShortcuts::QuickNote,
1081                combos: vec![(
1082                    ctrl_i,
1083                    Reach::Shadowed(KeyCombo::new(KeyModifiers::new(), KeyStrike::Tab)),
1084                )],
1085            },
1086        ];
1087
1088        let text = super::notice_text(&stranded, CtrlHSetting::Auto, CtrlHPolicy::Backspace);
1089        let ctrl_h_clause = text
1090            .split_once("; ")
1091            .map(|(first, _)| first)
1092            .unwrap_or_else(|| panic!("expected the ctrl_h clause joined with '; ': {text}"));
1093        assert!(
1094            ctrl_h_clause.contains("FocusSidebar"),
1095            "ctrl_h clause: {ctrl_h_clause}"
1096        );
1097        assert!(
1098            !ctrl_h_clause.contains("QuickNote"),
1099            "ctrl_h clause: {ctrl_h_clause}"
1100        );
1101        assert!(text.contains("QuickNote"), "{text}");
1102    }
1103
1104    /// Pins the width budget: the footer renders this flash as an unwrapped,
1105    /// centre-aligned `Paragraph` (`components::footer_bar.rs`), so a line
1106    /// wider than the terminal is clipped from the *right* — losing the
1107    /// `kimun doctor` pointer this flash exists to deliver. The common
1108    /// scenario (the shipped default, `auto` chose Backspace) must fit an
1109    /// 80-column footer with room to spare. A regression here means someone
1110    /// widened the wording without checking it still fits.
1111    #[test]
1112    fn the_single_clause_notice_fits_the_flash_width_budget() {
1113        use crate::app::ctrl_h::CtrlHPolicy;
1114        use crate::keys::key_combo::{KeyCombo, KeyModifiers};
1115        use crate::keys::key_strike::KeyStrike;
1116        use crate::keys::reachability::{Reach, Unreachable};
1117        use crate::settings::CtrlHSetting;
1118
1119        let ctrl_h = KeyCombo::new(KeyModifiers::new().and_ctrl(), KeyStrike::KeyH);
1120        let stranded = vec![Unreachable {
1121            action: ActionShortcuts::FocusSidebar,
1122            combos: vec![(
1123                ctrl_h,
1124                Reach::Shadowed(KeyCombo::new(KeyModifiers::new(), KeyStrike::Backspace)),
1125            )],
1126        }];
1127
1128        let text = super::notice_text(&stranded, CtrlHSetting::Auto, CtrlHPolicy::Backspace);
1129        assert!(
1130            text.chars().count() <= super::FLASH_WIDTH_BUDGET,
1131            "{} chars, over the {}-column budget: {text}",
1132            text.chars().count(),
1133            super::FLASH_WIDTH_BUDGET
1134        );
1135    }
1136}