Skip to main content

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}