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