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