kimun_notes/components/events.rs
1use std::num::NonZeroU64;
2use std::sync::Arc;
3use std::time::Duration;
4
5use ratatui::crossterm::event::{KeyEvent, MouseEvent};
6use tokio::sync::mpsc::UnboundedSender;
7
8use kimun_core::{NoteVault, nfs::VaultPath};
9
10use crate::components::sortable::SortState;
11
12/// Which panel a sort selection applies to.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum SortTarget {
15 Sidebar,
16 Query,
17 /// The open sortable note browser: the Ctrl+K search browser or the
18 /// Ctrl+O file finder.
19 Browser,
20}
21
22/// The surface a save-current-query action sourced its query from. Carried
23/// through the save-search dialog so the editor knows whether the Query
24/// panel's breadcrumb should re-pin after the save — by identity, not by
25/// comparing query text (equal text from different surfaces must not collide).
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum SaveSource {
28 QueryPanel,
29 NoteBrowser,
30}
31
32/// All events that flow through the system — both input events (from crossterm)
33/// and app-level messages sent by components / screens to the main loop.
34#[derive(Debug)]
35pub enum AppEvent {
36 Input(InputEvent),
37 OpenScreen(ScreenEvent),
38
39 // ── App-level messages ───────────────────────────────────────────────────
40 Quit,
41 Redraw,
42 /// Background RAG sync task reporting its connection/sync status. Rendered
43 /// in the editor footer.
44 RagStatus(crate::rag::RagStatus),
45 /// The connected server is out of date: its update check found a newer
46 /// release, or it predates version reporting (`None` clears it: up to
47 /// date, offline, or check disabled). Shown as a passive hint next to the
48 /// RAG status in the editor footer.
49 ServerUpdate(Option<crate::server_client::sync::ServerUpdate>),
50 Autosave,
51 /// A core write (the properties dialog) changed this note on disk: reload
52 /// the editor buffer from disk if it is the open note.
53 NoteReloadFromDisk(VaultPath),
54 /// Background autosave task finished. `saved_revision` carries the
55 /// editor's `content_revision` at the moment the save was *issued*
56 /// on success, `None` if the write failed. The editor screen uses
57 /// `path` to ignore stale completions for notes the user has
58 /// already navigated away from, and `saved_revision` to clear the
59 /// dirty flag iff the buffer is still at that revision (i.e. no
60 /// edits during the save). `NonZeroU64` because the editor's
61 /// `content_revision` is never zero.
62 AutosaveCompleted {
63 path: VaultPath,
64 saved_revision: Option<NonZeroU64>,
65 /// The note's recomputed title (first body line) from the save, so the
66 /// sidebar row can be retitled. `None` when the save failed.
67 title: Option<String>,
68 },
69 /// Open a note (or directory) — `emphasis` carries the originating
70 /// query's needles when the open comes from a query result, so the
71 /// editor lights up the matched spans. Use
72 /// [`AppEvent::open`] for the plain case.
73 OpenPath {
74 path: VaultPath,
75 emphasis: Option<Vec<String>>,
76 },
77 /// Open an attachment (a non-note file) in the editor area's read-only
78 /// attachment view. Sent by the file browser when an
79 /// attachment row is activated.
80 OpenAttachment(VaultPath),
81 FocusSidebar,
82 /// Switch the drawer to the given view and reveal it (sent by the
83 /// activity rail and, later, by leader paths / mouse clicks).
84 OpenDrawerView(crate::components::drawer::DrawerView),
85 /// Run the query `#<label>` in the FIND drawer (sent by the TAGS drawer).
86 RunTagQuery(String),
87 /// Jump the editor cursor to the heading an OUTLINE row stands for (sent
88 /// by the OUTLINE drawer).
89 JumpToHeading(crate::components::drawer_views::HeadingTarget),
90 /// Open the search query syntax reference (sent by the `[F1]` syntax
91 /// chips on the FIND query box and the Ctrl+K browser).
92 OpenQueryHelp,
93 /// Open the shown attachment with the OS default program (sent by the
94 /// attachment view's `Open externally` chip; the key is `FollowLink`).
95 OpenAttachmentExternally,
96 /// Open the sort dialog for a list (sent by a click on its sort chip).
97 OpenSortDialog(SortTarget),
98 /// Run a leader-tree action (sent by the command palette after it has
99 /// closed itself, so the action sees no open overlay).
100 ExecuteLeaderAction(crate::keys::leader::LeaderAction),
101 /// Show a transient footer flash — async tasks report results with it.
102 FlashMessage(String),
103 /// A flash for the next screen that can show one. Sent while a screen
104 /// that drops flashes is live (Start, before any editor exists); the app
105 /// parks it and delivers it on the next switch to the editor.
106 ParkFlash(String),
107 /// The self-update lifecycle (one owner in `app::handle_app_message` for the app-global
108 /// bookkeeping, one in the editor screen for display).
109 Update(UpdateFlow),
110 /// Apply (and optionally persist) a resolved theme — sent by the theme
111 /// picker: previews on selection move, persists on Enter. Carries the
112 /// full `Theme` so applying never re-reads the themes directory.
113 ApplyTheme {
114 theme: Box<crate::settings::themes::Theme>,
115 persist: bool,
116 },
117 /// Async-loaded backlink count for the link target under the editor
118 /// cursor (status line 2's `→ target · N backlinks` affordance).
119 LinkTargetMeta {
120 target: String,
121 count: usize,
122 },
123 /// Async-loaded backlink count for the note at `path` (status line 2).
124 BacklinkCountLoaded {
125 path: VaultPath,
126 count: usize,
127 },
128 /// Property count of a note for the status bar (async-loaded).
129 PropertyCountLoaded {
130 path: VaultPath,
131 count: usize,
132 },
133 /// Async-loaded workspace git summary for the status bar, `None` when
134 /// the workspace is not a git repository.
135 GitStatusLoaded(Option<String>),
136 /// Sent by PreferencesScreen when user confirms Save. The shared settings
137 /// reference already contains the updated values.
138 PreferencesSaved,
139 /// Sent by OnboardingScreen when the user confirms Finish on the summary
140 /// step. The shared settings already contain the committed draft; the App loop
141 /// rebuilds the vault and navigates to Start (same as PreferencesSaved).
142 OnboardingFinished,
143 /// Sent by PreferencesScreen when user discards or closes unchanged.
144 ClosePreferences,
145 /// Sent by VaultSection; PreferencesScreen::handle_app_message intercepts.
146 OpenFileBrowser,
147 /// Sent by IndexingSection; PreferencesScreen intercepts.
148 TriggerFastReindex,
149 TriggerFullReindex,
150 /// Sent by indexing tokio task on completion.
151 IndexingDone(Result<Duration, String>),
152 /// Open (or create) today's journal entry and switch to it in the editor.
153 OpenJournal,
154 /// Dismiss the active editor overlay (note browser, Saved Searches modal,
155 /// or dialog). The single close path for everything owned by `OverlayHost`.
156 CloseOverlay,
157 /// Follow the link under the editor cursor: note name/path or external URL.
158 FollowLink(String),
159 /// Open the search modal pre-filled with `#<name>` to browse notes by label.
160 FollowLabel(String),
161 /// Insert raw text at the editor's cursor (replacing any active selection).
162 /// Used by the screen layer to deliver async results back to the editor —
163 /// e.g. the markdown link generated after a clipboard image is saved as an attachment.
164 InsertAtCursor(String),
165
166 /// File-operation requests and confirmations — owned by the editor
167 /// screen's `handle_file_op`.
168 FileOp(FileOp),
169 /// An async result addressed to the open overlay (see **Overlay data** in
170 /// CONTEXT.md). Routed only to the `OverlayHost`; with no (or the wrong)
171 /// overlay open it is stale by definition and dropped.
172 OverlayData(OverlayData),
173 /// An async result addressed to the Ask workspace (see CONTEXT.md: Ask
174 /// workspace). Its own family — Ask is a panel, not an overlay, so it is
175 /// never routed through `OverlayData`.
176 Ask(AskData),
177
178 /// A vault was found to be structurally unusable (conflicts, invalid layout, etc.).
179 /// Carries a formatted, human-readable error message.
180 ///
181 /// Handled by `app::handle_app_message`, which clears the workspace,
182 /// saves settings, and opens the settings screen with an error overlay.
183 /// To add a new conflict source: emit this event from the detection site; no
184 /// other files need to change.
185 VaultConflict(String),
186
187 // ── Workspace messages ──────────────────────────────────────────────
188 /// User switched to a different workspace. Carries the workspace name.
189 /// Handled by the App loop to rebuild the vault and navigate to StartScreen.
190 WorkspaceSwitched(String),
191
192 /// The saved-search save/select flow — owned by the editor screen's
193 /// `handle_saved_search`.
194 SavedSearch(SavedSearchFlow),
195
196 /// Sort selection changed in the sort dialog — apply live to `target`
197 /// (through its `SortableList`). When `persist` is set (sidebar's "save
198 /// as default"), also write the choice to settings.
199 SortChanged {
200 target: SortTarget,
201 state: SortState,
202 persist: bool,
203 },
204}
205
206/// Async data addressed to the Ask workspace. Its own family — Ask is a
207/// panel, and `OverlayData` is routed only to the OverlayHost.
208#[derive(Debug)]
209pub enum AskData {
210 /// A completed (or failed) answer for the turn with this id. Stale ids
211 /// (cleared thread, superseded regenerate) are dropped by `Thread`.
212 AnswerReady {
213 turn_id: u64,
214 result: Result<(String, Vec<crate::ask::AskSource>), String>,
215 },
216 /// The note text the source reader asked for. `None` = load failed.
217 ReaderNote {
218 path: VaultPath,
219 text: Option<String>,
220 },
221}
222
223/// The self-update lifecycle. Two owners by design: the App loop (`app::handle_app_message`) keeps the
224/// app-global copy (seeding later-opened screens, persisting dismissals) and
225/// forwards; the editor screen owns display (footer indicator, dialog).
226#[derive(Debug, Clone)]
227pub enum UpdateFlow {
228 /// A newer release was found by the background update check.
229 Available(crate::update::UpdateStatus),
230 /// User chose "Update now" in the update dialog → run the self-update.
231 Apply,
232 /// User skipped a version in the update dialog → persist the dismissal and
233 /// clear the indicator. Carries the version being skipped.
234 Dismiss(String),
235 /// Open the update dialog for the currently-known update (manual check).
236 ShowDialog,
237 /// Self-update finished installing → clear the pending notice (restart
238 /// still required to run the new binary).
239 Applied,
240}
241
242/// File-operation requests (open a dialog) and confirmations (an operation
243/// succeeded). One owner: the editor screen's `handle_file_op`.
244#[derive(Debug, Clone)]
245pub enum FileOp {
246 /// Request to show the file-operations menu (delete / rename / move).
247 ShowMenu(VaultPath),
248 /// Request to show the delete confirmation dialog for the given entry.
249 ShowDelete(VaultPath),
250 /// Request to show the rename dialog for the given entry.
251 ShowRename(VaultPath),
252 /// Request to show the move dialog for the given entry.
253 ShowMove(VaultPath),
254 /// Open the properties dialog for a note (leader `n p`, palette, status bar).
255 ShowProperties(VaultPath),
256 /// Request to show the create-note dialog pre-filled with body content —
257 /// the Ask "save as note" action (`e` in `ThreadPanel`). Plain
258 /// creates (follow-link, missing-note open) go straight through
259 /// `ActiveDialog::create_note` inside the editor screen instead, since
260 /// they already hold `vault` and don't need to cross a component
261 /// boundary.
262 ShowCreateWithContent { path: VaultPath, content: String },
263 /// Notification that a note was just created at this path. The current
264 /// screen refreshes its sidebar if it is browsing the note's directory.
265 /// Opening the note is a separate concern (the creator emits `OpenPath`).
266 Created(VaultPath),
267 /// Confirmation that the given entry was successfully deleted.
268 Deleted(VaultPath),
269 /// Confirmation that an entry was successfully renamed.
270 Renamed { from: VaultPath, to: VaultPath },
271 /// Confirmation that an entry was successfully moved.
272 Moved { from: VaultPath, to: VaultPath },
273}
274
275/// One row of the pinned-notes dialog: the pin and whether its note is
276/// currently on disk (a pin is kept, and shown as missing, when it is not).
277#[derive(Debug, Clone, PartialEq, Eq)]
278pub struct PinnedRow {
279 pub path: VaultPath,
280 pub missing: bool,
281}
282
283/// An async result addressed to the open overlay — **Overlay data** in
284/// CONTEXT.md. The `OverlayHost` is the only consumer; arriving with no (or
285/// the wrong) overlay open means the overlay was closed or replaced while
286/// the task ran, so the result is stale and dropped.
287#[derive(Debug, Clone)]
288pub enum OverlayData {
289 /// Rename dialog: name availability check result.
290 RenameValidation { available: bool },
291 /// Move dialog: directory list has loaded.
292 MoveDirectoriesLoaded(Vec<VaultPath>),
293 /// Move dialog: fuzzy filter results are ready.
294 MoveFilterResults(Vec<VaultPath>),
295 /// Move dialog: destination existence check result.
296 MoveDestValidation { available: bool },
297 /// Save-search dialog: existing saved-search names have loaded (drives
298 /// the update/overwrite/save-new hint).
299 SavedSearchNamesLoaded(Vec<String>),
300 /// Pinned-notes dialog: the list (with existence flags) has loaded or
301 /// reloaded after an edit — or that load failed. Carries its own error
302 /// rather than sharing [`OverlayData::Error`], so a failure from some
303 /// other overlay-started task (a rename confirmed just before the
304 /// dialog opened) is never mistaken for the reload this dialog waits on.
305 PinnedNotesLoaded(Result<Vec<PinnedRow>, String>),
306 /// Every property key in the vault (search form), for key pickers.
307 PropertyKeysLoaded(Vec<String>),
308 /// The properties dialog's note (`path`), read: entries in file order, or
309 /// the read error. A dialog ignores a result for another note.
310 PropertiesLoaded {
311 path: VaultPath,
312 result: Result<Vec<kimun_core::note::PropertyEntry>, String>,
313 },
314 /// A properties-dialog write to `path` finished: flash text, or why it
315 /// failed. A dialog ignores a result for another note.
316 PropertyWritten {
317 path: VaultPath,
318 result: Result<String, crate::components::dialogs::properties_dialog::PropertyWriteError>,
319 },
320 /// An overlay-initiated operation failed; carries a human-readable
321 /// error message.
322 Error(String),
323}
324
325/// The saved-search save/select flow. One owner: the editor screen's
326/// `handle_saved_search`.
327#[derive(Debug, Clone)]
328pub enum SavedSearchFlow {
329 /// Persist a saved search (emitted by the save-search dialog on submit).
330 /// `source` is the surface the query was sourced from, decided when the
331 /// dialog opened — it drives whether the panel breadcrumb re-pins.
332 Confirmed {
333 name: String,
334 query: String,
335 source: SaveSource,
336 },
337 /// A saved search was written to disk (success path of `Confirmed`).
338 /// The editor re-pins the panel breadcrumb here — only once the write
339 /// actually succeeded.
340 Persisted {
341 name: String,
342 query: String,
343 source: SaveSource,
344 },
345 /// The background saved-search write failed; surface it to the user.
346 SaveFailed { name: String },
347 /// A saved search was chosen in the Saved Searches modal.
348 Selected { query: String, name: String },
349}
350
351impl AppEvent {
352 pub fn send_input(event: InputEvent) -> Self {
353 AppEvent::Input(event)
354 }
355
356 /// `OpenPath` without query emphasis — the common case.
357 pub fn open(path: kimun_core::nfs::VaultPath) -> Self {
358 AppEvent::OpenPath {
359 path,
360 emphasis: None,
361 }
362 }
363}
364
365// ── Input events ────────────────────────────────────────────────────────
366#[derive(Debug, Clone)]
367pub enum InputEvent {
368 Key(KeyEvent),
369 Mouse(MouseEvent),
370 /// Bracketed-paste payload from the terminal. On macOS this is what
371 /// Cmd+V delivers, since the terminal intercepts Cmd combos before they
372 /// reach the TUI. The string may be empty when the clipboard holds only
373 /// non-text content (e.g. an image).
374 Paste(String),
375}
376
377// ── Screen events ────────────────────────────────────────────────────────
378#[derive(Debug, Clone)]
379pub enum ScreenEvent {
380 Start,
381 OpenPreferences,
382 /// Open the guided-setup (onboarding) screen.
383 OpenOnboarding,
384 /// Open the settings screen with an error overlay already shown.
385 OpenPreferencesWithError(String),
386 /// Navigate to the editor for the given vault root path.
387 OpenEditor(Arc<NoteVault>, VaultPath),
388 /// Navigate to the browse screen for the given vault root and directory path.
389 OpenBrowse(Arc<NoteVault>, VaultPath),
390}
391
392/// Convenience alias used throughout the codebase.
393pub type AppTx = UnboundedSender<AppEvent>;
394
395/// Sender helpers for the create-then-open sequence shared by every
396/// note-creation site (create dialog, quick note, note browser, sidebar,
397/// journal).
398pub trait AppTxExt {
399 /// Announce a freshly created note so sidebars browsing its directory
400 /// refresh, then open it. The notification is gated on `created` (an
401 /// already-existing note needs no refresh); the note is opened regardless.
402 fn announce_and_open(&self, path: VaultPath, created: bool);
403}
404
405impl AppTxExt for AppTx {
406 fn announce_and_open(&self, path: VaultPath, created: bool) {
407 if created {
408 self.send(AppEvent::FileOp(FileOp::Created(path.clone())))
409 .ok();
410 }
411 self.send(AppEvent::open(path)).ok();
412 }
413}
414
415/// Build a `Send + Sync` callback that fires `AppEvent::Redraw` on the
416/// app event bus. Used by long-lived components (autocomplete query
417/// task, etc.) that need to wake the render loop from a background
418/// thread but should not be aware of `AppEvent` themselves.
419pub fn redraw_callback(tx: AppTx) -> Arc<dyn Fn() + Send + Sync + 'static> {
420 Arc::new(move || {
421 let _ = tx.send(AppEvent::Redraw);
422 })
423}
424
425#[cfg(test)]
426mod tests {
427 use super::*;
428
429 fn _assert_new_variants_exist(e: AppEvent) {
430 match e {
431 AppEvent::FileOp(FileOp::ShowDelete(_)) => {}
432 AppEvent::FileOp(FileOp::ShowRename(_)) => {}
433 AppEvent::FileOp(FileOp::ShowMove(_)) => {}
434 AppEvent::FileOp(FileOp::ShowCreateWithContent {
435 path: _,
436 content: _,
437 }) => {}
438 AppEvent::FileOp(FileOp::Deleted(_)) => {}
439 AppEvent::FileOp(FileOp::Renamed { from: _, to: _ }) => {}
440 AppEvent::FileOp(FileOp::Moved { from: _, to: _ }) => {}
441 AppEvent::OverlayData(OverlayData::Error(_)) => {}
442 AppEvent::Ask(AskData::AnswerReady {
443 turn_id: _,
444 result: _,
445 }) => {}
446 AppEvent::Ask(AskData::ReaderNote { path: _, text: _ }) => {}
447 _ => {}
448 }
449 }
450
451 #[test]
452 fn ask_data_variants_construct() {
453 let _ = AppEvent::Ask(AskData::AnswerReady {
454 turn_id: 1,
455 result: Ok(("answer".to_string(), vec![])),
456 });
457 let _ = AppEvent::Ask(AskData::AnswerReady {
458 turn_id: 2,
459 result: Err("failed".to_string()),
460 });
461 let _ = AppEvent::Ask(AskData::ReaderNote {
462 path: VaultPath::new("note.md"),
463 text: Some("body".to_string()),
464 });
465 let _ = AppEvent::Ask(AskData::ReaderNote {
466 path: VaultPath::new("missing.md"),
467 text: None,
468 });
469 }
470
471 #[test]
472 fn sort_events_construct() {
473 use crate::components::file_list::{SortField, SortOrder};
474 use crate::components::sortable::SortState;
475 for target in [SortTarget::Sidebar, SortTarget::Query, SortTarget::Browser] {
476 let _ = AppEvent::SortChanged {
477 target,
478 state: SortState {
479 field: SortField::Title,
480 order: SortOrder::Descending,
481 group_dirs: None,
482 },
483 persist: false,
484 };
485 }
486 }
487}