1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
use std::num::NonZeroU64;
use std::sync::Arc;
use std::time::Duration;
use ratatui::crossterm::event::{KeyEvent, MouseEvent};
use tokio::sync::mpsc::UnboundedSender;
use kimun_core::{NoteVault, nfs::VaultPath};
use crate::components::file_list::{SortField, SortOrder};
/// Which panel a sort selection applies to.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortTarget {
Sidebar,
Query,
}
/// The surface a save-current-query action sourced its query from. Carried
/// through the save-search dialog so the editor knows whether the Query
/// panel's breadcrumb should re-pin after the save — by identity, not by
/// comparing query text (equal text from different surfaces must not collide).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SaveSource {
QueryPanel,
NoteBrowser,
}
/// All events that flow through the system — both input events (from crossterm)
/// and app-level messages sent by components / screens to the main loop.
#[derive(Debug, Clone)]
pub enum AppEvent {
Input(InputEvent),
OpenScreen(ScreenEvent),
// ── App-level messages ───────────────────────────────────────────────────
Quit,
Redraw,
Autosave,
/// Background autosave task finished. `saved_revision` carries the
/// editor's `content_revision` at the moment the save was *issued*
/// on success, `None` if the write failed. The editor screen uses
/// `path` to ignore stale completions for notes the user has
/// already navigated away from, and `saved_revision` to clear the
/// dirty flag iff the buffer is still at that revision (i.e. no
/// edits during the save). `NonZeroU64` because the editor's
/// `content_revision` is never zero.
AutosaveCompleted {
path: VaultPath,
saved_revision: Option<NonZeroU64>,
/// The note's recomputed title (first body line) from the save, so the
/// sidebar row can be retitled. `None` when the save failed.
title: Option<String>,
},
/// Open a note (or directory) — `emphasis` carries the originating
/// query's needles when the open comes from a query result, so the
/// editor lights up the matched spans (spec §5.1). Use
/// [`AppEvent::open`] for the plain case.
OpenPath {
path: VaultPath,
emphasis: Option<Vec<String>>,
},
FocusSidebar,
/// Switch the drawer to the given view and reveal it (sent by the
/// activity rail and, later, by leader paths / mouse clicks).
OpenDrawerView(crate::components::drawer::DrawerView),
/// Run the query `#<label>` in the FIND drawer (sent by the TAGS drawer).
RunTagQuery(String),
/// Jump the editor cursor to the first heading with this text (sent by
/// the OUTLINE drawer).
JumpToHeading(String),
/// Run a leader-tree action (sent by the command palette after it has
/// closed itself, so the action sees no open overlay).
ExecuteLeaderAction(crate::keys::leader::LeaderAction),
/// Show a transient footer flash — async tasks report results with it.
FlashMessage(String),
/// A newer release was found by the background update check. Stored on
/// `App` and surfaced as a footer indicator on the editor screen.
UpdateAvailable(crate::update::UpdateStatus),
/// User chose "Update now" in the update dialog → run the self-update.
ApplyUpdate,
/// User skipped a version in the update dialog → persist the dismissal and
/// clear the indicator. Carries the version being skipped.
DismissUpdate(String),
/// Open the update dialog for the currently-known update (manual check).
ShowUpdateDialog,
/// Self-update finished installing → clear the pending notice (restart still
/// required to run the new binary).
UpdateApplied,
/// Apply (and optionally persist) a resolved theme — sent by the theme
/// picker: previews on selection move, persists on Enter. Carries the
/// full `Theme` so applying never re-reads the themes directory.
ApplyTheme {
theme: Box<crate::settings::themes::Theme>,
persist: bool,
},
/// Async-loaded backlink count for the link target under the editor
/// cursor (status line 2's `→ target · N backlinks` affordance).
LinkTargetMeta {
target: String,
count: usize,
},
/// Async-loaded backlink count for the note at `path` (status line 2).
BacklinkCountLoaded {
path: VaultPath,
count: usize,
},
/// Async-loaded workspace git summary for the status bar, `None` when
/// the workspace is not a git repository.
GitStatusLoaded(Option<String>),
/// Sent by PreferencesScreen when user confirms Save. The shared settings
/// reference already contains the updated values.
PreferencesSaved,
/// Sent by OnboardingScreen when the user confirms Finish on the summary
/// step. The shared settings already contain the committed draft; main.rs
/// rebuilds the vault and navigates to Start (same as PreferencesSaved).
OnboardingFinished,
/// Sent by PreferencesScreen when user discards or closes unchanged.
ClosePreferences,
/// Sent by VaultSection; PreferencesScreen::handle_app_message intercepts.
OpenFileBrowser,
/// Sent by IndexingSection; PreferencesScreen intercepts.
TriggerFastReindex,
TriggerFullReindex,
/// Sent by indexing tokio task on completion.
IndexingDone(Result<Duration, String>),
/// Open (or create) today's journal entry and switch to it in the editor.
OpenJournal,
/// Dismiss the active editor overlay (note browser, Saved Searches modal,
/// or dialog). The single close path for everything owned by `OverlayHost`.
CloseOverlay,
/// Follow the link under the editor cursor: note name/path or external URL.
FollowLink(String),
/// Open the search modal pre-filled with `#<name>` to browse notes by label.
FollowLabel(String),
/// Insert raw text at the editor's cursor (replacing any active selection).
/// Used by the screen layer to deliver async results back to the editor —
/// e.g. the markdown link generated after a clipboard image is saved as an attachment.
InsertAtCursor(String),
// ── File-operation dialog messages ───────────────────────────────────────
/// Request to show the file-operations menu (delete / rename / move).
ShowFileOpsMenu(VaultPath),
/// Request to show the delete confirmation dialog for the given entry.
ShowDeleteDialog(VaultPath),
/// Request to show the rename dialog for the given entry.
ShowRenameDialog(VaultPath),
/// Request to show the move dialog for the given entry.
ShowMoveDialog(VaultPath),
/// Confirmation that the given entry was successfully deleted.
EntryDeleted(VaultPath),
/// Confirmation that an entry was successfully renamed.
EntryRenamed {
from: VaultPath,
to: VaultPath,
},
/// Confirmation that an entry was successfully moved.
EntryMoved {
from: VaultPath,
to: VaultPath,
},
/// Notification that a note was just created at this path. The current
/// screen refreshes its sidebar if it is browsing the note's directory.
/// Opening the note is a separate concern (the creator emits `OpenPath`).
EntryCreated(VaultPath),
/// A dialog operation failed; carries a human-readable error message.
DialogError(String),
/// A vault was found to be structurally unusable (conflicts, invalid layout, etc.).
/// Carries a formatted, human-readable error message.
///
/// Handled by `handle_app_message` in `main.rs`, which clears the workspace,
/// saves settings, and opens the settings screen with an error overlay.
/// To add a new conflict source: emit this event from the detection site; no
/// other files need to change.
VaultConflict(String),
// ── Dialog async result messages ─────────────────────────────────────────
/// Rename dialog: name availability check result.
RenameValidation {
available: bool,
},
/// Move dialog: directory list has loaded.
MoveDirectoriesLoaded(Vec<VaultPath>),
/// Move dialog: fuzzy filter results are ready.
MoveFilterResults(Vec<VaultPath>),
/// Move dialog: destination existence check result.
MoveDestValidation {
available: bool,
},
/// Save-search dialog: existing saved-search names have loaded (drives
/// the update/overwrite/save-new hint).
SavedSearchNamesLoaded(Vec<String>),
// ── Workspace messages ──────────────────────────────────────────────
/// User switched to a different workspace. Carries the workspace name.
/// Handled by main.rs to rebuild the vault and navigate to StartScreen.
WorkspaceSwitched(String),
/// Persist a saved search (emitted by the save-search dialog on submit).
/// `source` is the surface the query was sourced from, decided when the
/// dialog opened — it drives whether the panel breadcrumb re-pins.
SaveSearchConfirmed {
name: String,
query: String,
source: SaveSource,
},
/// A saved search was written to disk (success path of
/// `SaveSearchConfirmed`). The editor re-pins the panel breadcrumb here —
/// only once the write actually succeeded.
SavedSearchPersisted {
name: String,
query: String,
source: SaveSource,
},
/// The background saved-search write failed; surface it to the user.
SavedSearchSaveFailed {
name: String,
},
/// A saved search was chosen in the Saved Searches modal.
SavedSearchSelected {
query: String,
name: String,
},
/// Sort selection changed in the sort dialog — apply live to `target`.
/// When `persist` is set (sidebar's "save as default"), also write the
/// choice to settings. `group_directories` is sidebar-only (the query panel
/// ignores it).
SortChanged {
target: SortTarget,
field: SortField,
order: SortOrder,
group_directories: bool,
persist: bool,
},
}
impl AppEvent {
pub fn send_input(event: InputEvent) -> Self {
AppEvent::Input(event)
}
/// `OpenPath` without query emphasis — the common case.
pub fn open(path: kimun_core::nfs::VaultPath) -> Self {
AppEvent::OpenPath {
path,
emphasis: None,
}
}
}
// ── Input events ────────────────────────────────────────────────────────
#[derive(Debug, Clone)]
pub enum InputEvent {
Key(KeyEvent),
Mouse(MouseEvent),
/// Bracketed-paste payload from the terminal. On macOS this is what
/// Cmd+V delivers, since the terminal intercepts Cmd combos before they
/// reach the TUI. The string may be empty when the clipboard holds only
/// non-text content (e.g. an image).
Paste(String),
}
// ── Screen events ────────────────────────────────────────────────────────
#[derive(Debug, Clone)]
pub enum ScreenEvent {
Start,
OpenPreferences,
/// Open the guided-setup (onboarding) screen.
OpenOnboarding,
/// Open the settings screen with an error overlay already shown.
OpenPreferencesWithError(String),
/// Navigate to the editor for the given vault root path.
OpenEditor(Arc<NoteVault>, VaultPath),
/// Navigate to the browse screen for the given vault root and directory path.
OpenBrowse(Arc<NoteVault>, VaultPath),
}
/// Convenience alias used throughout the codebase.
pub type AppTx = UnboundedSender<AppEvent>;
/// Sender helpers for the create-then-open sequence shared by every
/// note-creation site (create dialog, quick note, note browser, sidebar,
/// journal).
pub trait AppTxExt {
/// Announce a freshly created note so sidebars browsing its directory
/// refresh, then open it. The notification is gated on `created` (an
/// already-existing note needs no refresh); the note is opened regardless.
fn announce_and_open(&self, path: VaultPath, created: bool);
}
impl AppTxExt for AppTx {
fn announce_and_open(&self, path: VaultPath, created: bool) {
if created {
self.send(AppEvent::EntryCreated(path.clone())).ok();
}
self.send(AppEvent::open(path)).ok();
}
}
/// Build a `Send + Sync` callback that fires `AppEvent::Redraw` on the
/// app event bus. Used by long-lived components (autocomplete query
/// task, etc.) that need to wake the render loop from a background
/// thread but should not be aware of `AppEvent` themselves.
pub fn redraw_callback(tx: AppTx) -> Arc<dyn Fn() + Send + Sync + 'static> {
Arc::new(move || {
let _ = tx.send(AppEvent::Redraw);
})
}
#[cfg(test)]
mod tests {
use super::*;
fn _assert_new_variants_exist(e: AppEvent) {
match e {
AppEvent::ShowDeleteDialog(_) => {}
AppEvent::ShowRenameDialog(_) => {}
AppEvent::ShowMoveDialog(_) => {}
AppEvent::EntryDeleted(_) => {}
AppEvent::EntryRenamed { from: _, to: _ } => {}
AppEvent::EntryMoved { from: _, to: _ } => {}
AppEvent::DialogError(_) => {}
_ => {}
}
}
#[test]
fn sort_events_construct() {
use crate::components::file_list::{SortField, SortOrder};
let _ = AppEvent::SortChanged {
target: SortTarget::Sidebar,
field: SortField::Name,
order: SortOrder::Ascending,
group_directories: true,
persist: false,
};
let _ = AppEvent::SortChanged {
target: SortTarget::Query,
field: SortField::Title,
order: SortOrder::Descending,
group_directories: false,
persist: true,
};
}
}