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, Clone)]
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 (see ADR-0017). 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 with this text (sent by
77 /// 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 /// The self-update lifecycle (one owner in main.rs for the app-global
85 /// bookkeeping, one in the editor screen for display).
86 Update(UpdateFlow),
87 /// Apply (and optionally persist) a resolved theme — sent by the theme
88 /// picker: previews on selection move, persists on Enter. Carries the
89 /// full `Theme` so applying never re-reads the themes directory.
90 ApplyTheme {
91 theme: Box<crate::settings::themes::Theme>,
92 persist: bool,
93 },
94 /// Async-loaded backlink count for the link target under the editor
95 /// cursor (status line 2's `→ target · N backlinks` affordance).
96 LinkTargetMeta {
97 target: String,
98 count: usize,
99 },
100 /// Async-loaded backlink count for the note at `path` (status line 2).
101 BacklinkCountLoaded {
102 path: VaultPath,
103 count: usize,
104 },
105 /// Async-loaded workspace git summary for the status bar, `None` when
106 /// the workspace is not a git repository.
107 GitStatusLoaded(Option<String>),
108 /// Sent by PreferencesScreen when user confirms Save. The shared settings
109 /// reference already contains the updated values.
110 PreferencesSaved,
111 /// Sent by OnboardingScreen when the user confirms Finish on the summary
112 /// step. The shared settings already contain the committed draft; main.rs
113 /// rebuilds the vault and navigates to Start (same as PreferencesSaved).
114 OnboardingFinished,
115 /// Sent by PreferencesScreen when user discards or closes unchanged.
116 ClosePreferences,
117 /// Sent by VaultSection; PreferencesScreen::handle_app_message intercepts.
118 OpenFileBrowser,
119 /// Sent by IndexingSection; PreferencesScreen intercepts.
120 TriggerFastReindex,
121 TriggerFullReindex,
122 /// Sent by indexing tokio task on completion.
123 IndexingDone(Result<Duration, String>),
124 /// Open (or create) today's journal entry and switch to it in the editor.
125 OpenJournal,
126 /// Dismiss the active editor overlay (note browser, Saved Searches modal,
127 /// or dialog). The single close path for everything owned by `OverlayHost`.
128 CloseOverlay,
129 /// Follow the link under the editor cursor: note name/path or external URL.
130 FollowLink(String),
131 /// Open the search modal pre-filled with `#<name>` to browse notes by label.
132 FollowLabel(String),
133 /// Insert raw text at the editor's cursor (replacing any active selection).
134 /// Used by the screen layer to deliver async results back to the editor —
135 /// e.g. the markdown link generated after a clipboard image is saved as an attachment.
136 InsertAtCursor(String),
137
138 /// File-operation requests and confirmations — owned by the editor
139 /// screen's `handle_file_op`.
140 FileOp(FileOp),
141 /// An async result addressed to the open overlay (see **Overlay data** in
142 /// CONTEXT.md). Routed only to the `OverlayHost`; with no (or the wrong)
143 /// overlay open it is stale by definition and dropped.
144 OverlayData(OverlayData),
145
146 /// A vault was found to be structurally unusable (conflicts, invalid layout, etc.).
147 /// Carries a formatted, human-readable error message.
148 ///
149 /// Handled by `handle_app_message` in `main.rs`, which clears the workspace,
150 /// saves settings, and opens the settings screen with an error overlay.
151 /// To add a new conflict source: emit this event from the detection site; no
152 /// other files need to change.
153 VaultConflict(String),
154
155 // ── Workspace messages ──────────────────────────────────────────────
156 /// User switched to a different workspace. Carries the workspace name.
157 /// Handled by main.rs to rebuild the vault and navigate to StartScreen.
158 WorkspaceSwitched(String),
159
160 /// The saved-search save/select flow — owned by the editor screen's
161 /// `handle_saved_search`.
162 SavedSearch(SavedSearchFlow),
163
164 /// Sort selection changed in the sort dialog — apply live to `target`.
165 /// When `persist` is set (sidebar's "save as default"), also write the
166 /// choice to settings. `group_directories` is sidebar-only (the query panel
167 /// ignores it).
168 SortChanged {
169 target: SortTarget,
170 field: SortField,
171 order: SortOrder,
172 group_directories: bool,
173 persist: bool,
174 },
175}
176
177/// The self-update lifecycle. Two owners by design: `main.rs` keeps the
178/// app-global copy (seeding later-opened screens, persisting dismissals) and
179/// forwards; the editor screen owns display (footer indicator, dialog).
180#[derive(Debug, Clone)]
181pub enum UpdateFlow {
182 /// A newer release was found by the background update check.
183 Available(crate::update::UpdateStatus),
184 /// User chose "Update now" in the update dialog → run the self-update.
185 Apply,
186 /// User skipped a version in the update dialog → persist the dismissal and
187 /// clear the indicator. Carries the version being skipped.
188 Dismiss(String),
189 /// Open the update dialog for the currently-known update (manual check).
190 ShowDialog,
191 /// Self-update finished installing → clear the pending notice (restart
192 /// still required to run the new binary).
193 Applied,
194}
195
196/// File-operation requests (open a dialog) and confirmations (an operation
197/// succeeded). One owner: the editor screen's `handle_file_op`.
198#[derive(Debug, Clone)]
199pub enum FileOp {
200 /// Request to show the file-operations menu (delete / rename / move).
201 ShowMenu(VaultPath),
202 /// Request to show the delete confirmation dialog for the given entry.
203 ShowDelete(VaultPath),
204 /// Request to show the rename dialog for the given entry.
205 ShowRename(VaultPath),
206 /// Request to show the move dialog for the given entry.
207 ShowMove(VaultPath),
208 /// Notification that a note was just created at this path. The current
209 /// screen refreshes its sidebar if it is browsing the note's directory.
210 /// Opening the note is a separate concern (the creator emits `OpenPath`).
211 Created(VaultPath),
212 /// Confirmation that the given entry was successfully deleted.
213 Deleted(VaultPath),
214 /// Confirmation that an entry was successfully renamed.
215 Renamed { from: VaultPath, to: VaultPath },
216 /// Confirmation that an entry was successfully moved.
217 Moved { from: VaultPath, to: VaultPath },
218}
219
220/// An async result addressed to the open overlay — **Overlay data** in
221/// CONTEXT.md. The `OverlayHost` is the only consumer; arriving with no (or
222/// the wrong) overlay open means the overlay was closed or replaced while
223/// the task ran, so the result is stale and dropped.
224#[derive(Debug, Clone)]
225pub enum OverlayData {
226 /// Rename dialog: name availability check result.
227 RenameValidation { available: bool },
228 /// Move dialog: directory list has loaded.
229 MoveDirectoriesLoaded(Vec<VaultPath>),
230 /// Move dialog: fuzzy filter results are ready.
231 MoveFilterResults(Vec<VaultPath>),
232 /// Move dialog: destination existence check result.
233 MoveDestValidation { available: bool },
234 /// Save-search dialog: existing saved-search names have loaded (drives
235 /// the update/overwrite/save-new hint).
236 SavedSearchNamesLoaded(Vec<String>),
237 /// An overlay-initiated operation failed; carries a human-readable
238 /// error message.
239 Error(String),
240 /// A RAG answer job finished (or failed) — delivered to the answer
241 /// overlay. `request_id` correlates the result to the ask that produced
242 /// it, so a late answer from a closed/superseded ask can't clobber the
243 /// current overlay.
244 RagAnswerReady {
245 request_id: u64,
246 result: std::result::Result<crate::rag::RagAnswer, String>,
247 },
248}
249
250/// The saved-search save/select flow. One owner: the editor screen's
251/// `handle_saved_search`.
252#[derive(Debug, Clone)]
253pub enum SavedSearchFlow {
254 /// Persist a saved search (emitted by the save-search dialog on submit).
255 /// `source` is the surface the query was sourced from, decided when the
256 /// dialog opened — it drives whether the panel breadcrumb re-pins.
257 Confirmed {
258 name: String,
259 query: String,
260 source: SaveSource,
261 },
262 /// A saved search was written to disk (success path of `Confirmed`).
263 /// The editor re-pins the panel breadcrumb here — only once the write
264 /// actually succeeded.
265 Persisted {
266 name: String,
267 query: String,
268 source: SaveSource,
269 },
270 /// The background saved-search write failed; surface it to the user.
271 SaveFailed { name: String },
272 /// A saved search was chosen in the Saved Searches modal.
273 Selected { query: String, name: String },
274}
275
276impl AppEvent {
277 pub fn send_input(event: InputEvent) -> Self {
278 AppEvent::Input(event)
279 }
280
281 /// `OpenPath` without query emphasis — the common case.
282 pub fn open(path: kimun_core::nfs::VaultPath) -> Self {
283 AppEvent::OpenPath {
284 path,
285 emphasis: None,
286 }
287 }
288}
289
290// ── Input events ────────────────────────────────────────────────────────
291#[derive(Debug, Clone)]
292pub enum InputEvent {
293 Key(KeyEvent),
294 Mouse(MouseEvent),
295 /// Bracketed-paste payload from the terminal. On macOS this is what
296 /// Cmd+V delivers, since the terminal intercepts Cmd combos before they
297 /// reach the TUI. The string may be empty when the clipboard holds only
298 /// non-text content (e.g. an image).
299 Paste(String),
300}
301
302// ── Screen events ────────────────────────────────────────────────────────
303#[derive(Debug, Clone)]
304pub enum ScreenEvent {
305 Start,
306 OpenPreferences,
307 /// Open the guided-setup (onboarding) screen.
308 OpenOnboarding,
309 /// Open the settings screen with an error overlay already shown.
310 OpenPreferencesWithError(String),
311 /// Navigate to the editor for the given vault root path.
312 OpenEditor(Arc<NoteVault>, VaultPath),
313 /// Navigate to the browse screen for the given vault root and directory path.
314 OpenBrowse(Arc<NoteVault>, VaultPath),
315}
316
317/// Convenience alias used throughout the codebase.
318pub type AppTx = UnboundedSender<AppEvent>;
319
320/// Sender helpers for the create-then-open sequence shared by every
321/// note-creation site (create dialog, quick note, note browser, sidebar,
322/// journal).
323pub trait AppTxExt {
324 /// Announce a freshly created note so sidebars browsing its directory
325 /// refresh, then open it. The notification is gated on `created` (an
326 /// already-existing note needs no refresh); the note is opened regardless.
327 fn announce_and_open(&self, path: VaultPath, created: bool);
328}
329
330impl AppTxExt for AppTx {
331 fn announce_and_open(&self, path: VaultPath, created: bool) {
332 if created {
333 self.send(AppEvent::FileOp(FileOp::Created(path.clone())))
334 .ok();
335 }
336 self.send(AppEvent::open(path)).ok();
337 }
338}
339
340/// Build a `Send + Sync` callback that fires `AppEvent::Redraw` on the
341/// app event bus. Used by long-lived components (autocomplete query
342/// task, etc.) that need to wake the render loop from a background
343/// thread but should not be aware of `AppEvent` themselves.
344pub fn redraw_callback(tx: AppTx) -> Arc<dyn Fn() + Send + Sync + 'static> {
345 Arc::new(move || {
346 let _ = tx.send(AppEvent::Redraw);
347 })
348}
349
350#[cfg(test)]
351mod tests {
352 use super::*;
353
354 fn _assert_new_variants_exist(e: AppEvent) {
355 match e {
356 AppEvent::FileOp(FileOp::ShowDelete(_)) => {}
357 AppEvent::FileOp(FileOp::ShowRename(_)) => {}
358 AppEvent::FileOp(FileOp::ShowMove(_)) => {}
359 AppEvent::FileOp(FileOp::Deleted(_)) => {}
360 AppEvent::FileOp(FileOp::Renamed { from: _, to: _ }) => {}
361 AppEvent::FileOp(FileOp::Moved { from: _, to: _ }) => {}
362 AppEvent::OverlayData(OverlayData::Error(_)) => {}
363 _ => {}
364 }
365 }
366
367 #[test]
368 fn sort_events_construct() {
369 use crate::components::file_list::{SortField, SortOrder};
370 let _ = AppEvent::SortChanged {
371 target: SortTarget::Sidebar,
372 field: SortField::Name,
373 order: SortOrder::Ascending,
374 group_directories: true,
375 persist: false,
376 };
377 let _ = AppEvent::SortChanged {
378 target: SortTarget::Query,
379 field: SortField::Title,
380 order: SortOrder::Descending,
381 group_directories: false,
382 persist: true,
383 };
384 }
385}