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