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::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    /// The self-update lifecycle (one owner in `app::handle_app_message` 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; the App loop
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    /// An async result addressed to the Ask workspace (see CONTEXT.md: Ask
146    /// workspace). Its own family — Ask is a panel, not an overlay, so it is
147    /// never routed through `OverlayData`.
148    Ask(AskData),
149
150    /// A vault was found to be structurally unusable (conflicts, invalid layout, etc.).
151    /// Carries a formatted, human-readable error message.
152    ///
153    /// Handled by `app::handle_app_message`, which clears the workspace,
154    /// saves settings, and opens the settings screen with an error overlay.
155    /// To add a new conflict source: emit this event from the detection site; no
156    /// other files need to change.
157    VaultConflict(String),
158
159    // ── Workspace messages ──────────────────────────────────────────────
160    /// User switched to a different workspace. Carries the workspace name.
161    /// Handled by the App loop to rebuild the vault and navigate to StartScreen.
162    WorkspaceSwitched(String),
163
164    /// The saved-search save/select flow — owned by the editor screen's
165    /// `handle_saved_search`.
166    SavedSearch(SavedSearchFlow),
167
168    /// Sort selection changed in the sort dialog — apply live to `target`.
169    /// When `persist` is set (sidebar's "save as default"), also write the
170    /// choice to settings. `group_directories` is sidebar-only (the query panel
171    /// ignores it).
172    SortChanged {
173        target: SortTarget,
174        field: SortField,
175        order: SortOrder,
176        group_directories: bool,
177        persist: bool,
178    },
179}
180
181/// Async data addressed to the Ask workspace. Its own family — Ask is a
182/// panel, and `OverlayData` is routed only to the OverlayHost.
183#[derive(Debug)]
184pub enum AskData {
185    /// A completed (or failed) answer for the turn with this id. Stale ids
186    /// (cleared thread, superseded regenerate) are dropped by `Thread`.
187    AnswerReady {
188        turn_id: u64,
189        result: Result<(String, Vec<crate::ask::AskSource>), String>,
190    },
191    /// The note text the source reader asked for. `None` = load failed.
192    ReaderNote {
193        path: VaultPath,
194        text: Option<String>,
195    },
196}
197
198/// The self-update lifecycle. Two owners by design: the App loop (`app::handle_app_message`) keeps the
199/// app-global copy (seeding later-opened screens, persisting dismissals) and
200/// forwards; the editor screen owns display (footer indicator, dialog).
201#[derive(Debug, Clone)]
202pub enum UpdateFlow {
203    /// A newer release was found by the background update check.
204    Available(crate::update::UpdateStatus),
205    /// User chose "Update now" in the update dialog → run the self-update.
206    Apply,
207    /// User skipped a version in the update dialog → persist the dismissal and
208    /// clear the indicator. Carries the version being skipped.
209    Dismiss(String),
210    /// Open the update dialog for the currently-known update (manual check).
211    ShowDialog,
212    /// Self-update finished installing → clear the pending notice (restart
213    /// still required to run the new binary).
214    Applied,
215}
216
217/// File-operation requests (open a dialog) and confirmations (an operation
218/// succeeded). One owner: the editor screen's `handle_file_op`.
219#[derive(Debug, Clone)]
220pub enum FileOp {
221    /// Request to show the file-operations menu (delete / rename / move).
222    ShowMenu(VaultPath),
223    /// Request to show the delete confirmation dialog for the given entry.
224    ShowDelete(VaultPath),
225    /// Request to show the rename dialog for the given entry.
226    ShowRename(VaultPath),
227    /// Request to show the move dialog for the given entry.
228    ShowMove(VaultPath),
229    /// Request to show the create-note dialog pre-filled with body content —
230    /// the Ask "save as note" action (`e` in `ThreadPanel`). Plain
231    /// creates (follow-link, missing-note open) go straight through
232    /// `ActiveDialog::create_note` inside the editor screen instead, since
233    /// they already hold `vault` and don't need to cross a component
234    /// boundary.
235    ShowCreateWithContent { path: VaultPath, content: String },
236    /// Notification that a note was just created at this path. The current
237    /// screen refreshes its sidebar if it is browsing the note's directory.
238    /// Opening the note is a separate concern (the creator emits `OpenPath`).
239    Created(VaultPath),
240    /// Confirmation that the given entry was successfully deleted.
241    Deleted(VaultPath),
242    /// Confirmation that an entry was successfully renamed.
243    Renamed { from: VaultPath, to: VaultPath },
244    /// Confirmation that an entry was successfully moved.
245    Moved { from: VaultPath, to: VaultPath },
246}
247
248/// One row of the pinned-notes dialog: the pin and whether its note is
249/// currently on disk (a pin is kept, and shown as missing, when it is not).
250#[derive(Debug, Clone, PartialEq, Eq)]
251pub struct PinnedRow {
252    pub path: VaultPath,
253    pub missing: bool,
254}
255
256/// An async result addressed to the open overlay — **Overlay data** in
257/// CONTEXT.md. The `OverlayHost` is the only consumer; arriving with no (or
258/// the wrong) overlay open means the overlay was closed or replaced while
259/// the task ran, so the result is stale and dropped.
260#[derive(Debug, Clone)]
261pub enum OverlayData {
262    /// Rename dialog: name availability check result.
263    RenameValidation { available: bool },
264    /// Move dialog: directory list has loaded.
265    MoveDirectoriesLoaded(Vec<VaultPath>),
266    /// Move dialog: fuzzy filter results are ready.
267    MoveFilterResults(Vec<VaultPath>),
268    /// Move dialog: destination existence check result.
269    MoveDestValidation { available: bool },
270    /// Save-search dialog: existing saved-search names have loaded (drives
271    /// the update/overwrite/save-new hint).
272    SavedSearchNamesLoaded(Vec<String>),
273    /// Pinned-notes dialog: the list (with existence flags) has loaded or
274    /// reloaded after an edit — or that load failed. Carries its own error
275    /// rather than sharing [`OverlayData::Error`], so a failure from some
276    /// other overlay-started task (a rename confirmed just before the
277    /// dialog opened) is never mistaken for the reload this dialog waits on.
278    PinnedNotesLoaded(Result<Vec<PinnedRow>, String>),
279    /// An overlay-initiated operation failed; carries a human-readable
280    /// error message.
281    Error(String),
282}
283
284/// The saved-search save/select flow. One owner: the editor screen's
285/// `handle_saved_search`.
286#[derive(Debug, Clone)]
287pub enum SavedSearchFlow {
288    /// Persist a saved search (emitted by the save-search dialog on submit).
289    /// `source` is the surface the query was sourced from, decided when the
290    /// dialog opened — it drives whether the panel breadcrumb re-pins.
291    Confirmed {
292        name: String,
293        query: String,
294        source: SaveSource,
295    },
296    /// A saved search was written to disk (success path of `Confirmed`).
297    /// The editor re-pins the panel breadcrumb here — only once the write
298    /// actually succeeded.
299    Persisted {
300        name: String,
301        query: String,
302        source: SaveSource,
303    },
304    /// The background saved-search write failed; surface it to the user.
305    SaveFailed { name: String },
306    /// A saved search was chosen in the Saved Searches modal.
307    Selected { query: String, name: String },
308}
309
310impl AppEvent {
311    pub fn send_input(event: InputEvent) -> Self {
312        AppEvent::Input(event)
313    }
314
315    /// `OpenPath` without query emphasis — the common case.
316    pub fn open(path: kimun_core::nfs::VaultPath) -> Self {
317        AppEvent::OpenPath {
318            path,
319            emphasis: None,
320        }
321    }
322}
323
324// ── Input events ────────────────────────────────────────────────────────
325#[derive(Debug, Clone)]
326pub enum InputEvent {
327    Key(KeyEvent),
328    Mouse(MouseEvent),
329    /// Bracketed-paste payload from the terminal. On macOS this is what
330    /// Cmd+V delivers, since the terminal intercepts Cmd combos before they
331    /// reach the TUI. The string may be empty when the clipboard holds only
332    /// non-text content (e.g. an image).
333    Paste(String),
334}
335
336// ── Screen events ────────────────────────────────────────────────────────
337#[derive(Debug, Clone)]
338pub enum ScreenEvent {
339    Start,
340    OpenPreferences,
341    /// Open the guided-setup (onboarding) screen.
342    OpenOnboarding,
343    /// Open the settings screen with an error overlay already shown.
344    OpenPreferencesWithError(String),
345    /// Navigate to the editor for the given vault root path.
346    OpenEditor(Arc<NoteVault>, VaultPath),
347    /// Navigate to the browse screen for the given vault root and directory path.
348    OpenBrowse(Arc<NoteVault>, VaultPath),
349}
350
351/// Convenience alias used throughout the codebase.
352pub type AppTx = UnboundedSender<AppEvent>;
353
354/// Sender helpers for the create-then-open sequence shared by every
355/// note-creation site (create dialog, quick note, note browser, sidebar,
356/// journal).
357pub trait AppTxExt {
358    /// Announce a freshly created note so sidebars browsing its directory
359    /// refresh, then open it. The notification is gated on `created` (an
360    /// already-existing note needs no refresh); the note is opened regardless.
361    fn announce_and_open(&self, path: VaultPath, created: bool);
362}
363
364impl AppTxExt for AppTx {
365    fn announce_and_open(&self, path: VaultPath, created: bool) {
366        if created {
367            self.send(AppEvent::FileOp(FileOp::Created(path.clone())))
368                .ok();
369        }
370        self.send(AppEvent::open(path)).ok();
371    }
372}
373
374/// Build a `Send + Sync` callback that fires `AppEvent::Redraw` on the
375/// app event bus. Used by long-lived components (autocomplete query
376/// task, etc.) that need to wake the render loop from a background
377/// thread but should not be aware of `AppEvent` themselves.
378pub fn redraw_callback(tx: AppTx) -> Arc<dyn Fn() + Send + Sync + 'static> {
379    Arc::new(move || {
380        let _ = tx.send(AppEvent::Redraw);
381    })
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387
388    fn _assert_new_variants_exist(e: AppEvent) {
389        match e {
390            AppEvent::FileOp(FileOp::ShowDelete(_)) => {}
391            AppEvent::FileOp(FileOp::ShowRename(_)) => {}
392            AppEvent::FileOp(FileOp::ShowMove(_)) => {}
393            AppEvent::FileOp(FileOp::ShowCreateWithContent {
394                path: _,
395                content: _,
396            }) => {}
397            AppEvent::FileOp(FileOp::Deleted(_)) => {}
398            AppEvent::FileOp(FileOp::Renamed { from: _, to: _ }) => {}
399            AppEvent::FileOp(FileOp::Moved { from: _, to: _ }) => {}
400            AppEvent::OverlayData(OverlayData::Error(_)) => {}
401            AppEvent::Ask(AskData::AnswerReady {
402                turn_id: _,
403                result: _,
404            }) => {}
405            AppEvent::Ask(AskData::ReaderNote { path: _, text: _ }) => {}
406            _ => {}
407        }
408    }
409
410    #[test]
411    fn ask_data_variants_construct() {
412        let _ = AppEvent::Ask(AskData::AnswerReady {
413            turn_id: 1,
414            result: Ok(("answer".to_string(), vec![])),
415        });
416        let _ = AppEvent::Ask(AskData::AnswerReady {
417            turn_id: 2,
418            result: Err("failed".to_string()),
419        });
420        let _ = AppEvent::Ask(AskData::ReaderNote {
421            path: VaultPath::new("note.md"),
422            text: Some("body".to_string()),
423        });
424        let _ = AppEvent::Ask(AskData::ReaderNote {
425            path: VaultPath::new("missing.md"),
426            text: None,
427        });
428    }
429
430    #[test]
431    fn sort_events_construct() {
432        use crate::components::file_list::{SortField, SortOrder};
433        let _ = AppEvent::SortChanged {
434            target: SortTarget::Sidebar,
435            field: SortField::Name,
436            order: SortOrder::Ascending,
437            group_directories: true,
438            persist: false,
439        };
440        let _ = AppEvent::SortChanged {
441            target: SortTarget::Query,
442            field: SortField::Title,
443            order: SortOrder::Descending,
444            group_directories: false,
445            persist: true,
446        };
447    }
448}