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}