1use std::sync::Arc;
2use std::time::Duration;
3
4use async_trait::async_trait;
5use kimun_core::NoteVault;
6use kimun_core::error::{FSError, VaultError};
7use kimun_core::nfs::VaultPath;
8use ratatui::layout::{Constraint, Direction, Layout};
9use ratatui::style::Style;
10use ratatui::widgets::Paragraph;
11
12use crate::app_screen::ask::AskCoordinator;
13use crate::app_screen::editor_input::{
14 Classification, CycleDir, EditorIntent, EditorOp, InputCtx, OverlayOpen, PanelFallback,
15 classify,
16};
17use crate::app_screen::overlay_host::OverlayHost;
18use crate::app_screen::panel_set::PanelSet;
19use crate::app_screen::{AppScreen, ScreenKind};
20use crate::components::ask_thread::ThreadPanel;
21use crate::components::attachment_view::AttachmentView;
22use crate::components::autosave_timer::AutosaveTimer;
23use crate::components::dialogs::ActiveDialog;
24use crate::components::drawer::{DrawerHost, DrawerView};
25use crate::components::drawer_views::{LinksPanel, OutlinePanel, TagsPanel};
26use crate::components::event_state::EventState;
27use crate::components::events::{
28 AppEvent, AppTx, FileOp, InputEvent, OverlayData, SaveSource, SavedSearchFlow, ScreenEvent,
29 SortTarget, UpdateFlow,
30};
31use crate::components::file_list::FileListEntry;
32use crate::components::footer_bar::FooterBar;
33use crate::components::note_browser::file_finder_provider::FileFinderProvider;
34use crate::components::note_browser::search_provider::resolving_search_source;
35use crate::components::note_browser::{BrowserScope, NoteBrowserModal};
36use crate::components::overlay::{Overlay, OverlayKind};
37use crate::components::panel::PanelKind;
38use crate::components::query_panel::QueryPanel;
39use crate::components::saved_searches_modal::SavedSearchesModal;
40use crate::components::sidebar::SidebarComponent;
41use crate::components::text_editor::TextEditorComponent;
42use crate::keys::KeyBindings;
43use crate::keys::action_shortcuts::{ActionShortcuts, TextAction};
44use crate::keys::leader::{LeaderAction, LeaderEngine, LeaderOutcome};
45use crate::settings::SharedSettings;
46use crate::settings::icons::Icons;
47use crate::settings::themes::Theme;
48use crate::util::single_slot_task::SingleSlotTask;
49
50const SAVE_TIMEOUT: Duration = Duration::from_secs(5);
57
58pub struct EditorScreen {
59 vault: Arc<NoteVault>,
60 settings: SharedSettings,
61 icons: Icons,
62 theme: Theme,
63 panels: PanelSet,
67 path: VaultPath,
68 footer: FooterBar,
69 doc_meta: crate::app_screen::doc_meta::DocMeta,
72 update: Option<crate::update::UpdateStatus>,
75 rag_status: crate::rag::RagStatus,
78 ask: AskCoordinator,
81 leader: LeaderEngine,
83 clicks: crate::app_screen::click_run::ClickRun,
87 app_tx: Option<AppTx>,
90 autosave: AutosaveTimer,
91 overlays: OverlayHost<PanelKind>,
94 autosave_task: SingleSlotTask<()>,
103}
104
105impl EditorScreen {
106 pub fn new(vault: Arc<NoteVault>, path: VaultPath, settings: SharedSettings) -> Self {
107 let s = settings.read().unwrap();
108 let kb = s.key_bindings.clone();
109 let theme = s.get_theme();
110 let footer = FooterBar::new();
111 let icons = s.icons();
112 let sidebar = SidebarComponent::from_settings(vault.clone(), &s);
113 let query_panel = QueryPanel::new(vault.clone(), kb.clone(), icons.clone());
114 let semantic = crate::components::semantic_search::SemanticPanel::new(
115 vault.clone(),
116 settings.clone(),
117 s.icons(),
118 s.yank_combos(),
119 );
120 let tags = TagsPanel::new(vault.clone(), s.icons(), s.yank_combos());
121 let links = LinksPanel::new(vault.clone(), s.icons(), s.yank_combos());
122 let outline = OutlinePanel::new(vault.clone(), s.icons(), s.yank_combos());
123 let drawer = DrawerHost::new(
124 vault.clone(),
125 &kb,
126 sidebar,
127 query_panel,
128 semantic,
129 tags,
130 links,
131 outline,
132 );
133 let rail_kb = kb.clone();
134 let mut editor = TextEditorComponent::new(kb, &s);
135 editor.set_vault(vault.clone());
136 let leader_engine = LeaderEngine::with_tree(s.leader_tree());
137 drop(s);
138 let rail_icons = icons.clone();
139 let ask = AskCoordinator::new(settings.clone(), vault.clone());
140 Self {
141 settings,
142 icons,
143 theme,
144 panels: PanelSet::from_panels(
148 drawer,
149 editor,
150 rail_icons,
151 rail_kb,
152 crate::components::activity_rail::RailCaps {
153 semantic: false,
154 ask: false,
155 },
156 ),
157 doc_meta: crate::app_screen::doc_meta::DocMeta::new(vault.clone()),
158 update: None,
159 rag_status: crate::rag::RagStatus::Disabled,
160 ask,
161 vault,
162 path,
163 footer,
164 leader: leader_engine,
165 clicks: Default::default(),
166 app_tx: None,
167 autosave: AutosaveTimer::new(),
168 overlays: OverlayHost::new(),
169 autosave_task: SingleSlotTask::empty(),
170 }
171 }
172}
173
174fn encode_rgba_to_png(width: u32, height: u32, rgba: &[u8]) -> std::io::Result<Vec<u8>> {
176 let mut buf = Vec::new();
177 {
178 let mut encoder = png::Encoder::new(&mut buf, width, height);
179 encoder.set_color(png::ColorType::Rgba);
180 encoder.set_depth(png::BitDepth::Eight);
181 let mut writer = encoder
182 .write_header()
183 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
184 writer
185 .write_image_data(rgba)
186 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
187 }
188 Ok(buf)
189}
190
191impl EditorScreen {
192 fn try_paste_image(&mut self, tx: &AppTx) -> bool {
201 let Some(editor) = self.panels.editor_mut() else {
202 return false;
203 };
204 let img = match editor.take_clipboard_image() {
205 Some(i) if !i.rgba.is_empty() && i.width > 0 && i.height > 0 => i,
206 _ => return false,
207 };
208 let expected = img
211 .width
212 .checked_mul(img.height)
213 .and_then(|n| n.checked_mul(4));
214 if expected != Some(img.rgba.len()) {
215 self.footer
216 .flash("Clipboard image size mismatch".to_string(), tx);
217 return true;
218 }
219 let asset_path = self.vault.generate_attachment_path("image", "png");
225 let link_path = asset_path.relative_link_from_note(&self.path);
226 let markdown = format!("");
227 let vault = self.vault.clone();
228 let tx2 = tx.clone();
229 let width = img.width as u32;
230 let height = img.height as u32;
231 let rgba = img.rgba;
232 tokio::spawn(async move {
233 let png_bytes =
235 match tokio::task::spawn_blocking(move || encode_rgba_to_png(width, height, &rgba))
236 .await
237 {
238 Ok(Ok(b)) => b,
239 Ok(Err(e)) => {
240 tx2.send(AppEvent::OverlayData(OverlayData::Error(format!(
241 "Image encode failed: {e}"
242 ))))
243 .ok();
244 return;
245 }
246 Err(e) => {
247 tx2.send(AppEvent::OverlayData(OverlayData::Error(format!(
248 "Image encode task failed: {e}"
249 ))))
250 .ok();
251 return;
252 }
253 };
254 match vault.save_attachment(&asset_path, &png_bytes).await {
255 Ok(()) => {
256 tx2.send(AppEvent::InsertAtCursor(markdown)).ok();
257 tx2.send(AppEvent::FlashMessage("image pasted".into())).ok();
261 }
262 Err(e) => {
263 tx2.send(AppEvent::OverlayData(OverlayData::Error(format!(
264 "Image save failed: {e}"
265 ))))
266 .ok();
267 }
268 }
269 });
270 true
271 }
272
273 #[cfg(test)]
276 async fn persist_saved_search(&self, name: &str, query: &str) -> Result<(), VaultError> {
277 self.vault.save_search(name, query).await
278 }
279
280 async fn follow_link(&mut self, target: String, tx: &AppTx) {
281 if kimun_core::note::scan::is_remote_url(&target) {
283 match open::that_detached(&target) {
284 Ok(()) => self.footer.flash(format!("Opening {target}"), tx),
285 Err(e) => self.footer.flash(format!("Cannot open URL: {e}"), tx),
286 }
287 return;
288 }
289
290 if kimun_core::note::scan::target_looks_like_image(&target) {
294 let parent = self.path.get_parent_path().0;
295 let resolved = parent.append(&VaultPath::new(target.trim()));
296 let os_path = self.vault.path_to_pathbuf(&resolved);
297 match open::that_detached(&os_path) {
298 Ok(()) => self.footer.flash(format!("Opening {target}"), tx),
299 Err(e) => self.footer.flash(format!("Cannot open image: {e}"), tx),
300 }
301 return;
302 }
303
304 let target_clean = target.split('#').next().unwrap_or(&target).trim_end();
308 let path = kimun_core::nfs::VaultPath::note_path_from(target_clean)
312 .resolve_link_in_note(&self.path);
313 match self.vault.open_or_search(&path).await {
314 Ok(results) if results.is_empty() => {
315 self.present_overlay(Box::new(ActiveDialog::create_note(
316 path,
317 self.vault.clone(),
318 None,
319 )));
320 }
321 Ok(mut results) if results.len() == 1 => {
322 let (entry, _) = results.remove(0);
323 self.open_path(entry.path, None, tx).await;
324 }
325 Ok(results) => {
326 use crate::components::note_browser::link_results_provider::LinkResultsProvider;
327 let provider = LinkResultsProvider::from_results(results);
328 let s = self.settings.read().unwrap();
329 let modal = NoteBrowserModal::new(
330 format!("Follow: {target}"),
331 BrowserScope::Files,
332 provider,
333 self.vault.clone(),
334 s.key_bindings.clone(),
335 s.icons(),
336 tx.clone(),
337 );
338 drop(s);
339 self.present_overlay(Box::new(modal));
340 }
341 Err(e) => {
342 self.footer.flash(format!("Link error: {e}"), tx);
343 }
344 }
345 }
346
347 pub async fn open_path(&mut self, path: VaultPath, emphasis: Option<Vec<String>>, tx: &AppTx) {
348 if !path.is_note() {
349 if let Ok(kimun_core::EntryKind::Attachment) = self.vault.entry_kind(&path).await {
354 self.open_attachment(path, tx).await;
355 return;
356 }
357 tx.send(AppEvent::OpenScreen(ScreenEvent::OpenBrowse(
358 self.vault.clone(),
359 path,
360 )))
361 .ok();
362 return;
363 }
364
365 self.try_save().await;
367
368 self.path = path.clone();
369 self.hide_ask_if_shown();
374 self.panels.clear_attachment();
375 self.panels
377 .sidebar_mut()
378 .set_open_note(Some(self.path.clone()));
379 match self.vault.get_note_text(&self.path).await {
380 Ok(content) => {
381 self.settings.read().unwrap().add_path_history(&path);
385 let settings_snapshot = self.settings.read().unwrap().clone();
386 tokio::spawn(async move {
387 settings_snapshot.save_to_disk().ok();
388 });
389 self.doc_meta.note_opened(&self.path, tx);
390 if let Some(ed) = self.panels.editor_mut() {
391 ed.open_note(self.path.clone(), content);
392 if let Some(needles) = emphasis {
395 ed.set_search_needles(needles);
396 }
397 ed.set_redraw_tx(tx);
398 }
399 tx.send(AppEvent::Redraw).ok();
400 self.reflect_open_note_in_drawers(tx);
403 }
404 Err(e) => {
405 if matches!(e, VaultError::FSError(FSError::VaultPathNotFound { .. })) {
406 self.present_overlay(Box::new(ActiveDialog::create_note(
407 self.path.clone(),
408 self.vault.clone(),
409 None,
410 )));
411 } else {
412 tracing::error!("Failed to read note {}: {e}", self.path);
413 let parent = self.path.get_parent_path().0;
414 tx.send(AppEvent::OpenScreen(ScreenEvent::OpenBrowse(
415 self.vault.clone(),
416 parent,
417 )))
418 .ok();
419 }
420 return;
421 }
422 }
423
424 let note_parent = path.get_parent_path().0;
427 if self.panels.sidebar().is_empty() {
428 self.navigate_sidebar(note_parent, tx);
429 }
430
431 let interval = self.settings.read().unwrap().autosave_interval_secs;
433 self.autosave.restart(interval, tx.clone());
434 }
435
436 fn hide_ask_if_shown(&mut self) {
442 self.ask.hide_if_shown(&mut self.panels);
443 }
444
445 async fn open_attachment(&mut self, path: VaultPath, tx: &AppTx) {
451 self.try_save().await;
453 self.autosave_task.abort();
454
455 match self.vault.get_attachment_details(&path).await {
456 Ok(details) => {
457 let (icons, kb) = {
458 let s = self.settings.read().unwrap();
459 (s.icons(), s.key_bindings.clone())
460 };
461 let view = AttachmentView::new(details, icons, kb);
462 self.path = path;
463 self.hide_ask_if_shown();
466 self.panels.show_attachment(view);
467 self.panels.sidebar_mut().set_open_note(None);
468 tx.send(AppEvent::Redraw).ok();
469 }
470 Err(e) => {
471 self.footer
472 .flash(format!("Cannot open attachment: {e}"), tx);
473 }
474 }
475 }
476
477 fn navigate_sidebar(&mut self, dir: VaultPath, tx: &AppTx) {
478 self.panels.sidebar_mut().navigate(dir, tx);
482 }
483
484 fn refresh_sidebar_if_showing(&mut self, dir: &VaultPath, tx: &AppTx) {
490 self.panels.sidebar_mut().refresh_if_showing(dir, tx);
491 }
492
493 fn note_saved(&mut self, path: &VaultPath, raw_title: String) {
497 let title = FileListEntry::display_title(raw_title);
498 self.panels.sidebar_mut().update_note_row(path, &title);
499 }
500
501 async fn try_save(&mut self) {
502 if self.autosave_task.is_in_flight() {
519 match self.autosave_task.await_with_timeout(SAVE_TIMEOUT).await {
520 Some(_) => {} None => {
522 self.autosave_task.abort();
524 return;
525 }
526 }
527 }
528 let Some(text) = self
530 .panels
531 .editor()
532 .filter(|e| e.is_dirty())
533 .map(|e| e.get_text())
534 else {
535 return;
536 };
537 let save = self.vault.save_note(&self.path, &text);
541 if let Ok(Ok((_, content))) = tokio::time::timeout(SAVE_TIMEOUT, save).await {
542 if let Some(ed) = self.panels.editor_mut() {
543 ed.mark_saved(text);
544 }
545 let path = self.path.clone();
546 self.note_saved(&path, content.title);
547 }
548 }
549
550 fn spawn_autosave(&mut self, tx: &AppTx) {
559 if self.autosave_task.is_in_flight() {
562 return;
563 }
564 let Some(ed) = self.panels.editor() else {
565 return;
566 };
567 if !ed.is_dirty() {
568 return;
569 }
570 let text = ed.get_text();
571 let revision = ed.content_revision();
572 let vault = self.vault.clone();
573 let path = self.path.clone();
574 let tx = tx.clone();
575 self.autosave_task.spawn(async move {
576 let (saved_revision, title) = match vault.save_note(&path, &text).await {
577 Ok((_, content)) => (Some(revision), Some(content.title)),
578 Err(_) => (None, None),
579 };
580 let _ = tx.send(AppEvent::AutosaveCompleted {
581 path,
582 saved_revision,
583 title,
584 });
585 });
586 }
587
588 fn opener_focus(&self) -> PanelKind {
591 self.panels.focused()
592 }
593
594 fn present_overlay(&mut self, overlay: Box<dyn Overlay>) {
598 self.leader.cancel();
601 let opener = self.opener_focus();
602 self.overlays.open(overlay, opener);
603 }
604
605 fn dismiss_overlay(&mut self) {
611 if let Some(opener) = self.overlays.close() {
612 self.panels.focus(opener);
613 }
614 }
615
616 async fn on_entry_op(&mut self, from: VaultPath, tx: &AppTx) {
617 self.dismiss_overlay();
618 if from.is_like(&self.path) {
623 self.autosave.stop();
624 self.try_save().await;
625 let parent = self.path.get_parent_path().0;
626 tx.send(AppEvent::OpenScreen(ScreenEvent::OpenBrowse(
627 self.vault.clone(),
628 parent,
629 )))
630 .ok();
631 } else {
632 self.refresh_sidebar_if_showing(&from.get_parent_path().0, tx);
633 }
634 }
635
636 async fn on_note_renamed(&mut self, from: VaultPath, to: VaultPath, tx: &AppTx) {
642 self.dismiss_overlay();
643 self.panels.sidebar_mut().rename_note_row(&from, &to);
644 if from.is_like(&self.path) {
648 self.autosave_task.abort();
654 match self.vault.get_note_text(&to).await {
655 Ok(text) => {
656 self.path = to.clone();
657 if let Some(ed) = self.panels.editor_mut() {
658 ed.renamed_to(to.clone());
659 ed.set_text(text.clone());
660 ed.mark_saved(text);
661 }
662 self.panels
663 .sidebar_mut()
664 .set_open_note(Some(self.path.clone()));
665 self.doc_meta.note_opened(&self.path, tx);
666 self.reflect_open_note_in_drawers(tx);
667 let interval = self.settings.read().unwrap().autosave_interval_secs;
669 self.autosave.restart(interval, tx.clone());
670 }
671 Err(_) => {
672 self.autosave.stop();
677 let parent = to.get_parent_path().0;
678 tx.send(AppEvent::OpenScreen(ScreenEvent::OpenBrowse(
679 self.vault.clone(),
680 parent,
681 )))
682 .ok();
683 }
684 }
685 }
686 }
687}
688
689impl EditorScreen {
690 fn input_ctx(&self, double_click: bool) -> InputCtx {
697 InputCtx {
698 overlay: self.overlays.active_kind(),
699 leader_pending: self.leader.is_pending(),
700 focused: self.panels.focused(),
701 drawer_view: self.panels.active_drawer_view(),
702 space_leads: self.panels.editor().is_some_and(|e| e.space_leads()),
703 claim: self.panels.editor().map(|e| e.claim()).unwrap_or_default(),
704 double_click,
705 }
706 }
707
708 fn track_click(&mut self, event: &InputEvent, now: std::time::Instant) -> bool {
732 let InputEvent::Mouse(mouse) = event else {
733 self.clicks.end();
734 return false;
735 };
736 if self.overlays.is_open() {
737 self.clicks.end();
738 return false;
739 }
740 let on_buffer = matches!(
745 mouse.kind,
746 ratatui::crossterm::event::MouseEventKind::Down(_)
747 ) && self.panels.is_note_buffer_cell(mouse.column, mouse.row);
748 self.clicks.observe(mouse, on_buffer, now)
749 }
750
751 fn apply_classification(
755 &mut self,
756 classification: Classification,
757 event: &InputEvent,
758 tx: &AppTx,
759 ) -> EventState {
760 if let Some(chord) = classification.flash {
761 self.footer.flash(chord, tx);
762 }
763 if classification.cancel_leader {
764 self.leader.cancel();
765 }
766 self.execute_intent(classification.intent, event, tx)
767 }
768
769 fn execute_intent(
770 &mut self,
771 intent: EditorIntent,
772 event: &InputEvent,
773 tx: &AppTx,
774 ) -> EventState {
775 match intent {
776 EditorIntent::Consume => EventState::Consumed,
777 EditorIntent::EditorPaste => {
778 if !self.try_paste_image(tx)
779 && let InputEvent::Paste(text) = event
780 && !text.is_empty()
781 && let Some(ed) = self.panels.editor_mut()
782 {
783 ed.paste_text(text, tx);
784 }
785 EventState::Consumed
786 }
787 EditorIntent::ImageProbe => {
788 if self.try_paste_image(tx) {
789 EventState::Consumed
790 } else {
791 let ctx = self.input_ctx(false);
797 let classification = {
798 let s = self.settings.read().unwrap();
799 crate::app_screen::editor_input::classify_tail(
800 event,
801 &s.key_bindings,
802 &ctx,
803 false,
804 )
805 };
806 self.apply_classification(classification, event, tx)
807 }
808 }
809 EditorIntent::FollowLink => {
810 if self.follow_link_at_cursor(tx) {
811 EventState::Consumed
812 } else if matches!(event, InputEvent::Mouse(_)) {
813 self.execute_intent(EditorIntent::Mouse, event, tx)
821 } else {
822 EventState::Consumed
823 }
824 }
825 EditorIntent::LeaderKey(key) => self.handle_leader_key(&key, tx),
826 EditorIntent::LeaderStart => {
827 self.leader.start();
828 self.schedule_whichkey_reveal(tx);
829 EventState::Consumed
830 }
831 EditorIntent::Op(op) => {
832 self.run_op(op, tx);
833 EventState::Consumed
834 }
835 EditorIntent::ToggleOverlay { kind, open } => {
836 if self.overlays.active_kind() == Some(kind) {
837 self.dismiss_overlay();
838 } else {
839 self.open_overlay(open, tx);
840 }
841 EventState::Consumed
842 }
843 EditorIntent::OpenOverlay(open) => {
844 self.open_overlay(open, tx);
845 EventState::Consumed
846 }
847 EditorIntent::Overlay => self.overlays.handle_input(event, tx),
848 EditorIntent::Mouse => {
849 let state = self.panels.handle_mouse(event, tx);
853 self.ask.sync_sources(&mut self.panels, tx);
855 if self.panels.editor().is_some_and(|e| e.wants_context_menu) {
859 if let Some(ed) = self.panels.editor_mut() {
860 ed.wants_context_menu = false;
861 }
862 tx.send(AppEvent::FileOp(FileOp::ShowMenu(self.path.clone())))
863 .ok();
864 }
865 state
866 }
867 EditorIntent::Panel { fallback } => {
868 let state = self.panels.handle_input(event, tx);
869 self.ask.sync_sources(&mut self.panels, tx);
872 if state == EventState::NotConsumed {
873 match fallback {
874 PanelFallback::None => state,
875 PanelFallback::FocusCycle(CycleDir::Right) => {
876 self.focus_right(tx);
877 EventState::Consumed
878 }
879 PanelFallback::FocusCycle(CycleDir::Left) => {
880 self.focus_left(tx);
881 EventState::Consumed
882 }
883 PanelFallback::FocusEditor => {
884 self.focus_editor();
885 EventState::Consumed
886 }
887 }
888 } else {
889 state
890 }
891 }
892 }
893 }
894
895 fn run_op(&mut self, op: EditorOp, tx: &AppTx) {
896 match op {
897 EditorOp::ToggleDrawer => self.toggle_drawer(tx),
898 EditorOp::FocusLeft => self.focus_left(tx),
899 EditorOp::FocusRight => self.focus_right(tx),
900 EditorOp::OpenJournal => {
901 tx.send(AppEvent::OpenJournal).ok();
902 }
903 EditorOp::ShowFileOps => {
904 tx.send(AppEvent::FileOp(FileOp::ShowMenu(self.path.clone())))
905 .ok();
906 }
907 EditorOp::ToggleQueryPanel => self.toggle_backlinks(tx),
908 EditorOp::OpenFileBrowserReveal => {
909 self.open_drawer_view(DrawerView::Files, tx);
910 self.reveal_note_dir_in_sidebar(tx);
911 }
912 EditorOp::SaveCurrentQuery => {
913 if let Some((query, provenance, source)) = self.save_query_source() {
914 self.present_overlay(Box::new(ActiveDialog::save_search(
918 query,
919 provenance,
920 source,
921 self.vault.clone(),
922 tx,
923 )));
924 }
925 }
926 EditorOp::FindInBuffer => {
927 if let Some(ed) = self.panels.editor_mut() {
928 ed.open_or_advance_search();
929 }
930 }
931 EditorOp::ReplaceInBuffer => {
932 if let Some(ed) = self.panels.editor_mut() {
933 ed.open_replace();
934 }
935 }
936 EditorOp::ApplyText(text_action) => {
937 if let Some(ed) = self.panels.editor_mut() {
938 ed.apply_text_action(text_action);
939 }
940 }
941 EditorOp::OpenAsk => self.open_ask_workspace(tx),
942 }
943 }
944
945 fn open_overlay(&mut self, open: OverlayOpen, tx: &AppTx) {
950 if self.overlays.is_open() {
951 return;
952 }
953 let overlay = self.build_overlay(open, tx);
954 self.present_overlay(overlay);
955 }
956
957 fn open_note(&self) -> Option<&VaultPath> {
965 if self.panels.is_showing_ask() || !self.path.is_note() {
966 None
967 } else {
968 Some(&self.path)
969 }
970 }
971
972 fn open_note_or_flash(&self, tx: &AppTx) -> Option<VaultPath> {
974 let note = self.open_note().cloned();
975 if note.is_none() {
976 tx.send(AppEvent::FlashMessage("no note open".into())).ok();
977 }
978 note
979 }
980
981 fn toggle_pin_open_note(&self, tx: &AppTx) {
986 let Some(path) = self.open_note_or_flash(tx) else {
987 return;
988 };
989 let vault = self.vault.clone();
990 let tx = tx.clone();
991 tokio::spawn(async move {
992 let msg = match vault.toggle_pinned_note(&path).await {
993 Ok(kimun_core::PinToggle::Pinned { position }) => format!(
994 "pinned as {} of {}",
995 position + 1,
996 kimun_core::PINNED_NOTES_CAP
997 ),
998 Ok(kimun_core::PinToggle::Unpinned) => "unpinned".to_string(),
999 Ok(kimun_core::PinToggle::Full) => format!(
1000 "already {} pinned — unpin one first",
1001 kimun_core::PINNED_NOTES_CAP
1002 ),
1003 Err(VaultError::FSError(FSError::VaultPathNotFound { .. })) => {
1004 format!("note not found: {path}")
1005 }
1006 Err(e) => {
1007 tracing::warn!("failed to toggle pin on {path}: {e}");
1008 format!("could not update pinned notes: {e}")
1009 }
1010 };
1011 tx.send(AppEvent::FlashMessage(msg)).ok();
1012 });
1013 }
1014
1015 fn jump_to_pinned(&self, n: u8, tx: &AppTx) {
1018 let vault = self.vault.clone();
1019 let tx = tx.clone();
1020 tokio::spawn(async move {
1021 let pins = match vault.list_pinned_notes().await {
1022 Ok(pins) => pins,
1023 Err(e) => {
1024 tx.send(AppEvent::FlashMessage(format!(
1025 "could not read pinned notes: {e}"
1026 )))
1027 .ok();
1028 return;
1029 }
1030 };
1031 let Some(path) = pins.get(usize::from(n).saturating_sub(1)).cloned() else {
1032 tx.send(AppEvent::FlashMessage(format!("no pinned note {n}")))
1033 .ok();
1034 return;
1035 };
1036 if !vault.exists(&path).await {
1037 tx.send(AppEvent::FlashMessage(format!(
1038 "pinned note not found: {path}"
1039 )))
1040 .ok();
1041 return;
1042 }
1043 tx.send(AppEvent::OpenPath {
1044 path,
1045 emphasis: None,
1046 })
1047 .ok();
1048 });
1049 }
1050
1051 fn build_overlay(&self, open: OverlayOpen, tx: &AppTx) -> Box<dyn Overlay> {
1056 let s = self.settings.read().unwrap();
1057 match open {
1058 OverlayOpen::SearchBrowser => {
1061 let provider = resolving_search_source(
1062 self.vault.clone(),
1063 s.current_last_paths(),
1064 Some(self.path.clone()),
1065 );
1066 Box::new(NoteBrowserModal::new(
1067 "Note Browser",
1068 BrowserScope::Query,
1069 provider,
1070 self.vault.clone(),
1071 s.key_bindings.clone(),
1072 s.icons(),
1073 tx.clone(),
1074 ))
1075 }
1076 OverlayOpen::FileFinder => {
1079 let current_dir = self.path.get_parent_path().0;
1080 let provider = FileFinderProvider::new(self.vault.clone(), current_dir);
1081 Box::new(NoteBrowserModal::new(
1082 "Find Note",
1083 BrowserScope::Files,
1084 provider,
1085 self.vault.clone(),
1086 s.key_bindings.clone(),
1087 s.icons(),
1088 tx.clone(),
1089 ))
1090 }
1091 OverlayOpen::SavedSearches => Box::new(SavedSearchesModal::new(
1093 self.vault.clone(),
1094 s.key_bindings.clone(),
1095 s.icons(),
1096 tx.clone(),
1097 )),
1098 OverlayOpen::CommandPalette => {
1100 let gateway = s
1101 .key_bindings
1102 .first_combo_for(&ActionShortcuts::Leader)
1103 .unwrap_or_else(|| "leader".to_string());
1104 Box::new(
1105 crate::components::command_palette::CommandPaletteModal::new(
1106 &s.leader_tree(),
1107 &gateway,
1108 s.icons(),
1109 tx.clone(),
1110 ),
1111 )
1112 }
1113 OverlayOpen::WorkspaceSwitcher => Box::new(ActiveDialog::workspace_switcher(&s)),
1115 OverlayOpen::ThemePicker => Box::new(ActiveDialog::theme_picker(&s)),
1118 OverlayOpen::Help => Box::new(ActiveDialog::help(&s.key_bindings, &s.leader_tree())),
1119 OverlayOpen::QueryHelp => Box::new(ActiveDialog::query_syntax()),
1120 OverlayOpen::Cheatsheet => Box::new(ActiveDialog::cheatsheet(&s)),
1121 OverlayOpen::SortQuery => {
1122 let (field, order) = self.panels.query().current_order();
1123 Box::new(ActiveDialog::sort(SortTarget::Query, field, order, false))
1124 }
1125 OverlayOpen::SortSidebar => {
1126 let (field, order) = self.panels.sidebar().current_sort();
1127 Box::new(ActiveDialog::sort(
1128 SortTarget::Sidebar,
1129 field,
1130 order,
1131 self.panels.sidebar().group_dirs(),
1132 ))
1133 }
1134 OverlayOpen::QuickNote => Box::new(ActiveDialog::quick_note(self.vault.clone())),
1135 OverlayOpen::PinnedNotes => {
1137 Box::new(ActiveDialog::pinned_notes(self.vault.clone(), tx))
1138 }
1139 }
1140 }
1141
1142 fn handle_update(&mut self, flow: UpdateFlow, tx: &AppTx) {
1146 match flow {
1147 UpdateFlow::Available(status) => {
1148 self.update = Some(status);
1149 }
1150 UpdateFlow::ShowDialog => {
1151 if let Some(status) = self.update.clone() {
1152 self.present_overlay(Box::new(ActiveDialog::update(&status)));
1153 }
1154 }
1155 UpdateFlow::Dismiss(_) => {
1156 self.update = None;
1158 }
1159 UpdateFlow::Applied => {
1160 self.update = None;
1163 }
1164 UpdateFlow::Apply => {
1165 let tx2 = tx.clone();
1166 tx.send(AppEvent::FlashMessage("Downloading update…".into()))
1167 .ok();
1168 tokio::spawn(async move {
1169 let result = async {
1170 let latest = crate::update::latest_release().await?;
1171 crate::update::install(latest).await
1172 }
1173 .await;
1174 let msg = match result {
1175 Ok(()) => {
1176 tx2.send(AppEvent::Update(UpdateFlow::Applied)).ok();
1177 "Update installed — restart kimün to apply".to_string()
1178 }
1179 Err(e) => format!("Update failed: {e}"),
1180 };
1181 tx2.send(AppEvent::FlashMessage(msg)).ok();
1182 });
1183 }
1184 }
1185 }
1186
1187 async fn handle_file_op(&mut self, op: FileOp, tx: &AppTx) {
1190 match op {
1191 FileOp::ShowMenu(path) => {
1192 self.present_overlay(Box::new(ActiveDialog::file_ops_menu(path)));
1193 }
1194 FileOp::ShowDelete(path) => {
1195 self.present_overlay(Box::new(ActiveDialog::delete(path, self.vault.clone())));
1196 }
1197 FileOp::ShowRename(path) => {
1198 self.present_overlay(Box::new(ActiveDialog::rename(path, self.vault.clone())));
1199 }
1200 FileOp::ShowMove(path) => {
1201 self.present_overlay(Box::new(ActiveDialog::move_to(
1202 path,
1203 self.vault.clone(),
1204 tx,
1205 )));
1206 }
1207 FileOp::ShowCreateWithContent { path, content } => {
1208 let path = self.vault.free_note_path(&path).await;
1216 self.present_overlay(Box::new(ActiveDialog::create_note(
1217 path,
1218 self.vault.clone(),
1219 Some(content),
1220 )));
1221 }
1222 FileOp::Created(path) => {
1223 self.refresh_sidebar_if_showing(&path.get_parent_path().0, tx);
1227 }
1228 FileOp::Deleted(path) => {
1229 self.on_entry_op(path, tx).await;
1230 }
1231 FileOp::Renamed { from, to } => {
1232 if from.is_note() {
1235 self.on_note_renamed(from, to, tx).await;
1236 } else {
1237 self.on_entry_op(from, tx).await;
1238 }
1239 }
1240 FileOp::Moved { from, .. } => {
1241 self.on_entry_op(from, tx).await;
1242 }
1243 }
1244 }
1245
1246 fn handle_saved_search(&mut self, flow: SavedSearchFlow, tx: &AppTx) {
1248 match flow {
1249 SavedSearchFlow::Selected { query, name } => {
1250 self.overlays.close();
1254 self.apply_saved_search(query, name, tx);
1255 }
1256 SavedSearchFlow::Confirmed {
1257 name,
1258 query,
1259 source,
1260 } => {
1261 let vault = self.vault.clone();
1265 let tx = tx.clone();
1266 tokio::spawn(async move {
1267 match vault.save_search(&name, &query).await {
1268 Ok(()) => {
1269 tx.send(AppEvent::SavedSearch(SavedSearchFlow::Persisted {
1270 name,
1271 query,
1272 source,
1273 }))
1274 .ok();
1275 }
1276 Err(e) => {
1277 tracing::warn!("failed to save search '{}': {}", name, e);
1278 tx.send(AppEvent::SavedSearch(SavedSearchFlow::SaveFailed { name }))
1279 .ok();
1280 }
1281 }
1282 });
1283 }
1284 SavedSearchFlow::Persisted {
1291 name,
1292 query,
1293 source: SaveSource::QueryPanel,
1294 } if name.trim() != query.trim() => {
1295 self.panels.query_mut().repin_saved_search(name, &query);
1296 }
1297 SavedSearchFlow::Persisted { .. } => {}
1298 SavedSearchFlow::SaveFailed { name } => {
1299 self.footer
1300 .flash(format!("Failed to save search '{name}'"), tx);
1301 }
1302 }
1303 }
1304
1305 async fn handle_owned_message(&mut self, msg: AppEvent, tx: &AppTx) {
1308 match msg {
1309 AppEvent::RagStatus(status) => {
1310 let ask_was = self.rag_status.llm_available();
1311 let sem_was = self.rag_status.search_available();
1312 self.rag_status = status;
1313 let ask_now = status.llm_available();
1319 let sem_now = status.search_available();
1320 if ask_was != ask_now || sem_was != sem_now {
1321 let (kb, icons) = {
1322 let s = self.settings.read().unwrap();
1323 (s.key_bindings.clone(), s.icons())
1324 };
1325 self.panels.rebuild_rail(
1326 kb,
1327 icons,
1328 crate::components::activity_rail::RailCaps {
1329 semantic: sem_now,
1330 ask: ask_now,
1331 },
1332 );
1333 }
1334 if ask_was != ask_now {
1335 self.ask
1336 .refresh_capability(&mut self.panels, self.rag_status)
1337 .await;
1338 }
1339 }
1340 AppEvent::Ask(data) => self.ask.handle_data(&mut self.panels, data, tx),
1341 AppEvent::FlashMessage(msg) => {
1344 self.footer.flash(msg, tx);
1345 }
1346 AppEvent::ExecuteLeaderAction(action) => {
1347 if self.overlays.is_open() {
1348 tracing::warn!("ExecuteLeaderAction({action:?}) dropped: overlay open");
1351 } else {
1352 self.execute_leader_action(action, tx);
1353 }
1354 }
1355 AppEvent::ApplyTheme { theme, persist } => {
1356 {
1359 let mut s = self.settings.write().unwrap();
1360 s.set_theme(theme.name.clone());
1361 }
1362 self.theme = (*theme).adapt_to_terminal();
1363 if persist {
1364 let snapshot = self.settings.read().unwrap().clone();
1365 tokio::spawn(async move {
1366 snapshot.save_to_disk().ok();
1367 });
1368 }
1369 tx.send(AppEvent::Redraw).ok();
1370 }
1371 AppEvent::RunTagQuery(label) if !self.overlays.is_open() => {
1374 self.open_find_with_query(format!("#{label}"), None, tx);
1375 }
1376 AppEvent::JumpToHeading(heading) if !self.overlays.is_open() => {
1377 if let Some(ed) = self.panels.editor_mut() {
1378 ed.jump_to_heading(&heading);
1379 }
1380 self.focus_editor();
1381 }
1382 AppEvent::OpenDrawerView(view) => {
1383 if view == DrawerView::Semantic && !self.rag_status.search_available() {
1387 let msg = if crate::rag::rag_configured(&self.settings) {
1388 "Semantic search needs a reachable server with an embedder"
1389 } else {
1390 "Set kimun_server_url in config to use semantic search"
1391 };
1392 tx.send(AppEvent::FlashMessage(msg.into())).ok();
1393 }
1394 else if view == DrawerView::Ask && !self.rag_status.llm_available() {
1397 tx.send(AppEvent::FlashMessage(
1398 "Ask needs an LLM-configured server; this one is semantic-search only"
1399 .into(),
1400 ))
1401 .ok();
1402 }
1403 else if self.panels.is_visible(PanelKind::Drawer)
1406 && self.panels.active_drawer_view() == view
1407 {
1408 self.panels.hide(PanelKind::Drawer);
1409 } else {
1410 self.open_drawer_view(view, tx);
1411 }
1412 }
1413 AppEvent::CloseOverlay => {
1414 self.dismiss_overlay();
1420 }
1421 AppEvent::SortChanged {
1422 target,
1423 field,
1424 order,
1425 group_directories,
1426 persist,
1427 } => {
1428 match target {
1429 SortTarget::Sidebar if persist => {
1430 let is_journal = self.panels.sidebar().is_current_journal();
1435 self.panels
1436 .sidebar_mut()
1437 .save_default(field, order, group_directories);
1438 {
1439 let mut s = self.settings.write().unwrap();
1440 if is_journal {
1441 s.journal_sort_field =
1442 crate::settings::SortFieldSetting::from(field);
1443 s.journal_sort_order =
1444 crate::settings::SortOrderSetting::from(order);
1445 } else {
1446 s.default_sort_field =
1447 crate::settings::SortFieldSetting::from(field);
1448 s.default_sort_order =
1449 crate::settings::SortOrderSetting::from(order);
1450 }
1451 s.group_directories = group_directories;
1452 }
1453 let snapshot = self.settings.read().unwrap().clone();
1454 tokio::spawn(async move {
1455 snapshot.save_to_disk().ok();
1456 });
1457 }
1458 SortTarget::Sidebar => {
1459 self.panels
1460 .sidebar_mut()
1461 .apply_sort(field, order, group_directories)
1462 }
1463 SortTarget::Query => self.panels.query_mut().apply_sort(field, order, tx),
1466 }
1467 }
1468 AppEvent::Autosave => {
1469 self.spawn_autosave(tx);
1470 }
1471 AppEvent::AutosaveCompleted {
1472 path,
1473 saved_revision,
1474 title,
1475 } => {
1476 if path == self.path
1477 && let Some(rev) = saved_revision
1478 && let Some(ed) = self.panels.editor_mut()
1479 {
1480 ed.mark_saved_at_revision(rev);
1481 }
1482 if let Some(raw_title) = title {
1483 self.note_saved(&path, raw_title);
1484 }
1485 self.doc_meta.refresh_git(tx);
1488 }
1497 AppEvent::FocusSidebar => {
1498 self.focus_sidebar(tx);
1499 }
1500 AppEvent::FollowLink(target) => {
1501 self.follow_link(target, tx).await;
1502 }
1503 AppEvent::FollowLabel(name) => {
1504 let initial = format!("#{name}");
1505 let s = self.settings.read().unwrap();
1506 let provider = resolving_search_source(
1507 self.vault.clone(),
1508 s.current_last_paths(),
1509 Some(self.path.clone()),
1510 );
1511 let modal = NoteBrowserModal::with_initial_query(
1512 "Note Browser",
1513 BrowserScope::Query,
1514 provider,
1515 self.vault.clone(),
1516 s.key_bindings.clone(),
1517 s.icons(),
1518 tx.clone(),
1519 initial,
1520 );
1521 drop(s);
1522 self.present_overlay(Box::new(modal));
1523 }
1524 AppEvent::InsertAtCursor(text) if self.panels.focused() == PanelKind::Editor => {
1525 if let Some(ed) = self.panels.editor_mut() {
1526 ed.insert_at_cursor(&text, tx);
1527 }
1528 }
1529 _ => {}
1530 }
1531 }
1532
1533 pub fn focus_editor(&mut self) {
1534 self.panels.focus(PanelKind::Editor);
1535 }
1536
1537 fn drawer_open_on(&self, view: DrawerView) -> bool {
1539 self.panels.is_visible(PanelKind::Drawer) && self.panels.active_drawer_view() == view
1540 }
1541
1542 fn reflect_open_note_in_drawers(&mut self, tx: &AppTx) {
1547 let path = self.path.clone();
1548 if self.drawer_open_on(DrawerView::Find) {
1549 self.panels.query_mut().set_note(path.clone(), tx.clone());
1550 }
1551 if self.panels.is_visible(PanelKind::Drawer) {
1552 match self.panels.active_drawer_view() {
1553 DrawerView::Links => self.panels.links_mut().set_note(path.clone(), tx),
1554 DrawerView::Outline => self.panels.outline_mut().set_note(path, tx),
1555 _ => {}
1556 }
1557 }
1558 }
1559
1560 fn reveal_note_dir_in_sidebar(&mut self, tx: &AppTx) {
1564 let note_parent = self.path.get_parent_path().0;
1565 if self.panels.sidebar().is_empty()
1566 || !note_parent.is_like(self.panels.sidebar().current_dir())
1567 {
1568 self.navigate_sidebar(note_parent, tx);
1569 }
1570 }
1571
1572 pub fn focus_sidebar(&mut self, tx: &AppTx) {
1576 if !self.panels.is_visible(PanelKind::Drawer) {
1577 self.open_drawer_view(DrawerView::Files, tx);
1581 } else {
1582 self.panels.focus(PanelKind::Drawer);
1583 }
1584 }
1585
1586 fn open_drawer_view(&mut self, view: DrawerView, tx: &AppTx) {
1590 self.ask.transition(&mut self.panels, view, tx);
1594 let newly_shown = !self.drawer_open_on(view);
1595 self.panels.open_drawer_view(view);
1596 self.drawer_view_revealed(view, newly_shown, tx);
1597 if view == DrawerView::Ask {
1601 self.panels.focus(PanelKind::Editor);
1602 } else {
1603 self.panels.focus(PanelKind::Drawer);
1604 }
1605 }
1606
1607 fn ensure_ask_workspace(&mut self, tx: &AppTx) {
1611 if !self.panels.is_showing_ask() {
1612 self.open_drawer_view(DrawerView::Ask, tx);
1613 }
1614 }
1615
1616 fn with_ask_panel(&mut self, tx: &AppTx, f: impl FnOnce(&mut ThreadPanel, &AppTx)) {
1620 self.ensure_ask_workspace(tx);
1621 f(self.panels.ask_mut(), tx);
1622 }
1623
1624 fn drawer_view_revealed(&mut self, view: DrawerView, newly_shown: bool, tx: &AppTx) {
1632 match view {
1633 DrawerView::Find if newly_shown => {
1634 self.panels
1635 .query_mut()
1636 .set_note(self.path.clone(), tx.clone());
1637 }
1638 DrawerView::Files if newly_shown => {
1639 self.reveal_note_dir_in_sidebar(tx);
1640 }
1641 DrawerView::Config => {
1642 let info = {
1643 let s = self.settings.read().unwrap();
1644 let key_of = |a: &ActionShortcuts| {
1645 s.key_bindings
1646 .first_combo_for(a)
1647 .unwrap_or_else(|| "unbound".to_string())
1648 };
1649 crate::components::drawer::ConfigInfo {
1650 theme_name: s.get_theme().name,
1651 leader_key: key_of(&ActionShortcuts::Leader),
1652 preferences_key: key_of(&ActionShortcuts::OpenPreferences),
1653 leader_timeout_ms: s.leader_timeout_ms,
1654 config_path: s
1655 .config_file
1656 .as_ref()
1657 .map(|p| p.display().to_string())
1658 .unwrap_or_else(|| "default location".to_string()),
1659 }
1660 };
1661 self.panels.drawer_set_config_info(info);
1662 }
1663 DrawerView::Semantic => self.panels.semantic_mut().ensure_source(tx),
1664 DrawerView::Tags => self.panels.tags_mut().refresh(tx),
1665 DrawerView::Links => self.panels.links_mut().set_note(self.path.clone(), tx),
1666 DrawerView::Outline => self.panels.outline_mut().set_note(self.path.clone(), tx),
1667 _ => {}
1668 }
1669 }
1670
1671 fn apply_text_from_leader(&mut self, action: TextAction, tx: &AppTx) {
1681 if self.input_ctx(false).accepts_text_action() {
1682 self.run_op(EditorOp::ApplyText(action), tx);
1683 } else {
1684 self.footer
1685 .flash("formatting needs the note focused".to_string(), tx);
1686 }
1687 }
1688
1689 fn focus_left(&mut self, _tx: &AppTx) {
1691 if let Some(kind) = self.panels.prev_kind() {
1692 self.panels.focus(kind);
1693 }
1694 }
1695
1696 fn focus_right(&mut self, _tx: &AppTx) {
1698 if let Some(kind) = self.panels.next_kind() {
1699 self.panels.focus(kind);
1700 }
1701 }
1702
1703 fn toggle_drawer(&mut self, tx: &AppTx) {
1710 if self.panels.is_visible(PanelKind::Drawer) {
1711 self.panels.hide(PanelKind::Drawer);
1712 } else {
1713 self.panels.show(PanelKind::Drawer);
1714 self.drawer_view_revealed(self.panels.active_drawer_view(), true, tx);
1715 }
1716 }
1717
1718 fn save_query_source(&self) -> Option<(String, Option<String>, SaveSource)> {
1724 let (query, provenance, source) = match self.overlays.active_kind() {
1725 Some(OverlayKind::NoteBrowser) => (
1726 self.overlays.active_query().unwrap_or_default().to_string(),
1727 self.overlays
1728 .active_saved_search_provenance()
1729 .map(str::to_string),
1730 SaveSource::NoteBrowser,
1731 ),
1732 None => (
1733 self.panels.query().active_query().to_string(),
1734 self.panels.query().saved_search_name().map(str::to_string),
1735 SaveSource::QueryPanel,
1736 ),
1737 Some(_) => return None,
1738 };
1739 if query.trim().is_empty() {
1740 None
1741 } else {
1742 Some((query, provenance, source))
1743 }
1744 }
1745
1746 fn schedule_whichkey_reveal(&self, tx: &AppTx) {
1751 let timeout = {
1752 let s = self.settings.read().unwrap();
1753 std::time::Duration::from_millis(s.leader_timeout_ms)
1754 };
1755 let tx2 = tx.clone();
1756 tokio::spawn(async move {
1757 tokio::time::sleep(timeout + std::time::Duration::from_millis(10)).await;
1758 let _ = tx2.send(AppEvent::Redraw);
1759 });
1760 }
1761
1762 fn open_ask_workspace(&mut self, tx: &AppTx) {
1771 if self.overlays.is_open() {
1772 return;
1773 }
1774 if !self.panels.is_showing_ask() {
1775 if !self.rag_status.llm_available() {
1776 tx.send(AppEvent::FlashMessage(
1777 "Ask needs an LLM-configured server; this one is semantic-search only".into(),
1778 ))
1779 .ok();
1780 return;
1781 }
1782 self.open_drawer_view(DrawerView::Ask, tx);
1783 }
1784 self.panels.ask_mut().focus_composer();
1785 self.panels.focus(PanelKind::Editor);
1786 }
1787
1788 fn follow_link_at_cursor(&mut self, tx: &AppTx) -> bool {
1796 use crate::components::text_editor::FollowTarget;
1797 if self.panels.is_showing_attachment() {
1800 self.open_attachment_externally(tx);
1801 return true;
1802 }
1803 let Some(editor) = self.panels.editor_mut() else {
1804 return false;
1805 };
1806 match editor.follow_target_at_cursor() {
1807 Some(FollowTarget::Link(target)) => {
1808 tx.send(AppEvent::FollowLink(target)).ok();
1809 true
1810 }
1811 Some(FollowTarget::Label(name)) => {
1812 tx.send(AppEvent::FollowLabel(name)).ok();
1813 true
1814 }
1815 None => false,
1816 }
1817 }
1818
1819 fn open_attachment_externally(&mut self, tx: &AppTx) {
1822 let Some(path) = self.panels.attachment_path() else {
1823 return;
1824 };
1825 let os_path = self.vault.path_to_pathbuf(path);
1826 match open::that_detached(&os_path) {
1827 Ok(()) => self
1828 .footer
1829 .flash(format!("Opening {}", os_path.display()), tx),
1830 Err(e) => self.footer.flash(format!("Cannot open: {e}"), tx),
1831 }
1832 }
1833
1834 fn handle_leader_key(
1839 &mut self,
1840 key: &ratatui::crossterm::event::KeyEvent,
1841 tx: &AppTx,
1842 ) -> EventState {
1843 use ratatui::crossterm::event::{KeyCode, KeyModifiers};
1844 match key.code {
1845 KeyCode::Esc => {
1846 self.leader.cancel();
1847 self.focus_editor();
1848 }
1849 KeyCode::Backspace => {
1850 let outcome = self.leader.step_up();
1851 if outcome == LeaderOutcome::SteppedUp {
1853 self.schedule_whichkey_reveal(tx);
1854 }
1855 }
1856 KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
1857 match self.leader.feed(c) {
1858 LeaderOutcome::Fired(action) => self.execute_leader_action(action, tx),
1859 LeaderOutcome::Invalid => {
1860 self.footer.flash(format!("leader: no entry for '{c}'"), tx);
1862 self.schedule_whichkey_reveal(tx);
1863 }
1864 LeaderOutcome::Descended => self.schedule_whichkey_reveal(tx),
1865 _ => {}
1866 }
1867 }
1868 _ => {}
1872 }
1873 tx.send(AppEvent::Redraw).ok();
1874 EventState::Consumed
1875 }
1876
1877 fn execute_leader_action(&mut self, action: LeaderAction, tx: &AppTx) {
1880 match action {
1881 LeaderAction::OpenDrawer(view) => self.open_drawer_view(view, tx),
1882
1883 LeaderAction::AppCheckUpdates => {
1884 if let Some(status) = self.update.clone() {
1885 self.present_overlay(Box::new(ActiveDialog::update(&status)));
1886 } else {
1887 let tx2 = tx.clone();
1889 tx.send(AppEvent::FlashMessage("Checking for updates…".into()))
1890 .ok();
1891 tokio::spawn(async move {
1892 let Ok(config_dir) = crate::settings::config_dir() else {
1893 return;
1894 };
1895 match crate::update::check_now(config_dir, true).await {
1896 Ok(Some(status)) if status.update_available => {
1897 tx2.send(AppEvent::Update(UpdateFlow::Available(status)))
1901 .ok();
1902 tx2.send(AppEvent::Update(UpdateFlow::ShowDialog)).ok();
1903 }
1904 Ok(_) => {
1905 tx2.send(AppEvent::FlashMessage("kimün is up to date".into()))
1906 .ok();
1907 }
1908 Err(e) => {
1909 tx2.send(AppEvent::FlashMessage(format!(
1910 "Update check failed: {e}"
1911 )))
1912 .ok();
1913 }
1914 }
1915 });
1916 }
1917 }
1918
1919 LeaderAction::FindFiles => self.open_overlay(OverlayOpen::FileFinder, tx),
1922 LeaderAction::FindGrep => self.open_overlay(OverlayOpen::SearchBrowser, tx),
1923 LeaderAction::FindTags => self.open_drawer_view(DrawerView::Tags, tx),
1924 LeaderAction::FindBacklinks => {
1925 self.open_find_with_query("<{note}".to_string(), None, tx)
1926 }
1927 LeaderAction::FindSaved => self.open_overlay(OverlayOpen::SavedSearches, tx),
1928 LeaderAction::FindRecent => self.open_overlay(OverlayOpen::SearchBrowser, tx),
1929 LeaderAction::FindHeadings => self.open_drawer_view(DrawerView::Outline, tx),
1930
1931 LeaderAction::NoteNew => {
1933 self.open_drawer_view(DrawerView::Files, tx);
1937 self.footer
1938 .flash("type a name — Enter creates".to_string(), tx);
1939 }
1940 LeaderAction::NoteDaily => {
1941 tx.send(AppEvent::OpenJournal).ok();
1942 }
1943 LeaderAction::NoteFromTemplate => {
1944 self.footer.flash("templates — coming soon".to_string(), tx);
1945 }
1946 LeaderAction::NoteRename => {
1947 if let Some(path) = self.open_note_or_flash(tx) {
1948 tx.send(AppEvent::FileOp(FileOp::ShowRename(path))).ok();
1949 }
1950 }
1951 LeaderAction::NoteMove => {
1952 if let Some(path) = self.open_note_or_flash(tx) {
1953 tx.send(AppEvent::FileOp(FileOp::ShowMove(path))).ok();
1954 }
1955 }
1956 LeaderAction::NoteDelete => {
1957 if let Some(path) = self.open_note_or_flash(tx) {
1958 tx.send(AppEvent::FileOp(FileOp::ShowDelete(path))).ok();
1959 }
1960 }
1961
1962 LeaderAction::LinksTab(tab) => {
1964 self.open_drawer_view(DrawerView::Links, tx);
1965 self.panels.links_mut().show_tab(tab, tx);
1966 }
1967 LeaderAction::LinksGraph => {
1968 self.footer
1969 .flash("local graph — coming soon".to_string(), tx);
1970 }
1971
1972 LeaderAction::GitStatus => {
1975 self.doc_meta.refresh_git(tx);
1976 let msg = self
1977 .doc_meta
1978 .git()
1979 .cloned()
1980 .unwrap_or_else(|| "not a git repository".to_string());
1981 self.footer.flash(msg, tx);
1982 }
1983 LeaderAction::GitSync | LeaderAction::GitLog | LeaderAction::GitDiff => {
1984 self.footer
1985 .flash("git is display-only for now".to_string(), tx);
1986 }
1987
1988 LeaderAction::VaultSwitch => self.open_overlay(OverlayOpen::WorkspaceSwitcher, tx),
1990 LeaderAction::VaultReindex => {
1991 let vault = self.vault.clone();
1994 let tx2 = tx.clone();
1995 self.footer.flash("reindexing…".to_string(), tx);
1996 tokio::spawn(async move {
1997 let started = std::time::Instant::now();
1998 let result = vault.index_notes(kimun_core::NotesValidation::Fast).await;
1999 let msg = match result {
2000 Ok(_) => format!("reindexed in {:.1?}", started.elapsed()),
2001 Err(e) => format!("reindex failed: {e}"),
2002 };
2003 tx2.send(AppEvent::FlashMessage(msg)).ok();
2004 });
2005 }
2006 LeaderAction::VaultConfig => self.open_drawer_view(DrawerView::Config, tx),
2009 LeaderAction::VaultTheme => self.open_overlay(OverlayOpen::ThemePicker, tx),
2010 LeaderAction::VaultPreferences => {
2011 tx.send(AppEvent::OpenScreen(ScreenEvent::OpenPreferences))
2012 .ok();
2013 }
2014 LeaderAction::AppOnboarding => {
2015 tx.send(AppEvent::OpenScreen(ScreenEvent::OpenOnboarding))
2016 .ok();
2017 }
2018
2019 LeaderAction::WindowZen => {
2021 self.panels.hide(PanelKind::Drawer);
2022 self.focus_editor();
2023 }
2024 LeaderAction::WindowSplit => {
2025 self.footer
2026 .flash("editor splits — coming soon".to_string(), tx);
2027 }
2028 LeaderAction::WindowGrowDrawer => self.panels.adjust_drawer_width(4),
2029 LeaderAction::WindowShrinkDrawer => self.panels.adjust_drawer_width(-4),
2030
2031 LeaderAction::NoteToggleTodo => {
2033 self.footer
2034 .flash("toggle todo — coming soon".to_string(), tx);
2035 }
2036 LeaderAction::NotePreview => {
2037 self.footer
2038 .flash("preview — lands with phase 09".to_string(), tx);
2039 }
2040 LeaderAction::NoteCopyWikilink => {
2041 if let Some(path) = self.open_note_or_flash(tx) {
2042 let link = format!("[[{}]]", path.get_clean_name());
2043 crate::components::yank(link, "wikilink copied", tx);
2044 }
2045 }
2046 LeaderAction::NoteExport => {
2047 self.footer.flash("export — coming soon".to_string(), tx);
2048 }
2049 LeaderAction::NoteYankPath => {
2050 if let Some(path) = self.open_note_or_flash(tx) {
2051 crate::components::yank(path.to_string(), "note path copied", tx);
2052 }
2053 }
2054
2055 LeaderAction::TextBold => self.apply_text_from_leader(TextAction::Bold, tx),
2061 LeaderAction::TextItalic => self.apply_text_from_leader(TextAction::Italic, tx),
2062 LeaderAction::TextStrikethrough => {
2063 self.apply_text_from_leader(TextAction::Strikethrough, tx)
2064 }
2065
2066 LeaderAction::AskFocus => self.open_ask_workspace(tx),
2068 LeaderAction::AskNew => {
2069 self.with_ask_panel(tx, |panel, _tx| {
2070 panel.thread_mut().clear();
2071 panel.focus_composer();
2072 });
2073 self.panels.ask_sources_mut().reset(tx);
2074 }
2075 LeaderAction::AskCopy => {
2076 self.with_ask_panel(tx, |panel, tx| panel.copy_selected(tx));
2077 }
2078 LeaderAction::AskSave => {
2079 self.with_ask_panel(tx, |panel, tx| panel.save_selected(tx));
2080 }
2081 LeaderAction::AskRegenerate => {
2082 self.with_ask_panel(tx, |panel, tx| panel.regenerate_selected(tx));
2083 self.ask.sync_sources(&mut self.panels, tx);
2084 }
2085 LeaderAction::AskSource => {
2086 self.ensure_ask_workspace(tx);
2087 self.ask.sync_sources_from_selected(&mut self.panels, tx);
2090 self.panels.ask_sources_mut().open_reader(0, tx);
2091 self.panels.focus(PanelKind::Drawer);
2092 }
2093
2094 LeaderAction::Palette => self.open_overlay(OverlayOpen::CommandPalette, tx),
2095 LeaderAction::Help => self.open_overlay(OverlayOpen::Cheatsheet, tx),
2096
2097 LeaderAction::NoteSave => {
2098 self.spawn_autosave(tx);
2101 }
2102 LeaderAction::AppQuit => {
2103 tx.send(AppEvent::Quit).ok();
2104 }
2105
2106 LeaderAction::FindPinned => self.open_overlay(OverlayOpen::PinnedNotes, tx),
2108 LeaderAction::NoteTogglePin => self.toggle_pin_open_note(tx),
2109 LeaderAction::PinnedJump(n) => self.jump_to_pinned(n, tx),
2110 }
2111 }
2112
2113 fn open_find_with_query(&mut self, query: String, name: Option<String>, tx: &AppTx) {
2123 self.panels.open_drawer_view(DrawerView::Find);
2124 self.panels.query_mut().apply_query(query, name, tx.clone());
2125 self.panels.focus(PanelKind::Drawer);
2126 }
2127
2128 fn apply_saved_search(&mut self, query: String, name: String, tx: &AppTx) {
2129 self.open_find_with_query(query, Some(name), tx);
2134 }
2135
2136 fn toggle_backlinks(&mut self, tx: &AppTx) {
2137 if self.drawer_open_on(DrawerView::Find) {
2138 self.panels.hide(PanelKind::Drawer);
2139 } else {
2140 self.open_drawer_view(DrawerView::Find, tx);
2141 }
2142 }
2143}
2144
2145#[async_trait]
2146impl AppScreen for EditorScreen {
2147 fn get_kind(&self) -> ScreenKind {
2148 ScreenKind::Editor
2149 }
2150
2151 async fn on_enter(&mut self, tx: &AppTx) {
2152 self.app_tx = Some(tx.clone());
2153 self.open_path(self.path.clone(), None, tx).await;
2154 }
2155
2156 fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
2157 let double_click = self.track_click(event, std::time::Instant::now());
2165 let ctx = self.input_ctx(double_click);
2166 let classification = if matches!(event, InputEvent::Key(_)) {
2170 let s = self.settings.read().unwrap();
2171 classify(event, &s.key_bindings, &ctx)
2172 } else {
2173 classify(event, &KeyBindings::empty(), &ctx)
2174 };
2175 self.apply_classification(classification, event, tx)
2176 }
2177
2178 fn render(&mut self, f: &mut ratatui::Frame) {
2179 let theme = &self.theme;
2180 f.render_widget(
2181 ratatui::widgets::Block::default().style(theme.base_style()),
2182 f.area(),
2183 );
2184
2185 let rows = Layout::default()
2186 .direction(Direction::Vertical)
2187 .constraints([
2188 Constraint::Length(1),
2189 Constraint::Min(0),
2190 Constraint::Length(crate::components::footer_bar::STATUS_BAR_HEIGHT),
2191 ])
2192 .split(f.area());
2193
2194 let workspace_badge = {
2198 let s = self.settings.read().unwrap();
2199 s.workspace_config.as_ref().map(|wc| {
2200 ratatui::text::Line::from(vec![
2201 ratatui::text::Span::styled(
2202 self.icons.workspace,
2203 Style::default().fg(theme.accent.to_ratatui()),
2204 ),
2205 ratatui::text::Span::styled(
2206 format!(" {}", wc.global.current_workspace),
2207 Style::default().fg(theme.gray.to_ratatui()),
2208 ),
2209 ])
2210 })
2211 };
2212 let workspace_label_width = workspace_badge
2213 .as_ref()
2214 .map(ratatui::text::Line::width)
2215 .unwrap_or_default();
2216 let breadcrumb = self
2217 .path
2218 .to_string()
2219 .trim_start_matches('/')
2220 .replace('/', " / ");
2221 let title_cols = Layout::default()
2222 .direction(Direction::Horizontal)
2223 .constraints([
2224 Constraint::Min(0),
2225 Constraint::Length(workspace_label_width as u16 + 2),
2226 ])
2227 .split(rows[0]);
2228 f.render_widget(
2229 Paragraph::new(ratatui::text::Line::from(vec![
2230 ratatui::text::Span::styled(
2231 " Kimün ",
2232 Style::default()
2233 .fg(theme.accent.to_ratatui())
2234 .add_modifier(ratatui::style::Modifier::BOLD),
2235 ),
2236 ratatui::text::Span::styled(
2237 format!("─ {breadcrumb}"),
2238 Style::default().fg(theme.fg_secondary.to_ratatui()),
2239 ),
2240 ])),
2241 title_cols[0],
2242 );
2243 if let Some(badge) = workspace_badge {
2244 f.render_widget(
2245 Paragraph::new(badge).alignment(ratatui::layout::Alignment::Right),
2246 title_cols[1],
2247 );
2248 }
2249
2250 self.panels
2253 .render(f, rows[1], theme, !self.overlays.is_open());
2254
2255 let (focus_label, hints) = if let Some(kind) = self.overlays.active_kind() {
2259 (kind.label(), self.overlays.hint_shortcuts())
2260 } else {
2261 (self.panels.focused_label(), self.panels.focused_hints())
2262 };
2263 let editing = if let Some(kind) = self.overlays.active_kind() {
2264 !matches!(kind, OverlayKind::Dialog)
2267 } else {
2268 match self.panels.focused() {
2269 PanelKind::Editor => true,
2270 PanelKind::Drawer => self.panels.drawer_is_text_input(),
2271 PanelKind::Rail => false,
2272 }
2273 };
2274 let path_str = self.path.to_string();
2275 let link_segment = if self.panels.focused() == PanelKind::Editor && !self.overlays.is_open()
2278 {
2279 let link = self
2280 .panels
2281 .editor()
2282 .and_then(|e| e.follow_target_at_cursor());
2283 self.doc_meta
2284 .link_segment(link.as_ref(), &self.path, self.app_tx.as_ref())
2285 } else {
2286 None
2287 };
2288 let ln_col = (self.panels.focused() == PanelKind::Editor && !self.overlays.is_open())
2291 .then(|| {
2292 self.panels.editor().map(|e| {
2293 let (row, col) = e.cursor_pos();
2294 (row + 1, col + 1)
2295 })
2296 })
2297 .flatten();
2298 let matches = (self.panels.focused() == PanelKind::Drawer
2300 && self.panels.active_drawer_view() == DrawerView::Find)
2301 .then(|| self.panels.query().result_count());
2302 let (global_hints, leader_timeout, gateway_label) = {
2303 let s = self.settings.read().unwrap();
2304 (
2305 crate::components::hints::global_hints(&s.key_bindings),
2306 std::time::Duration::from_millis(s.leader_timeout_ms),
2307 s.key_bindings
2308 .first_combo_for(&ActionShortcuts::Leader)
2309 .unwrap_or_else(|| "leader".to_string()),
2310 )
2311 };
2312 let ctx = crate::components::footer_bar::StatusContext {
2313 focus_label,
2314 editing,
2315 hints: &hints,
2316 global_hints: &global_hints,
2317 doc: crate::components::footer_bar::DocState {
2318 path: &path_str,
2319 dirty: self.panels.editor().is_some_and(|e| e.is_dirty()),
2320 ln_col,
2321 backlinks: self.doc_meta.backlinks(),
2322 git: self.doc_meta.git().cloned(),
2323 matches,
2324 link: link_segment,
2325 update: self
2326 .update
2327 .as_ref()
2328 .map(|u| format!("⬆ {} available", u.latest)),
2329 rag: self.rag_status.label().map(|s| s.to_string()),
2330 },
2331 };
2332 self.footer.render(f, rows[2], theme, &ctx);
2333
2334 let whichkey_visible = self
2337 .leader
2338 .pending_since()
2339 .is_some_and(|since| since.elapsed() >= leader_timeout);
2340 if whichkey_visible {
2341 let gateway = gateway_label;
2342 let area = f.area();
2343 let h = crate::components::which_key::desired_height(&self.leader, area.width)
2344 .min(rows[1].height);
2345 let rect =
2346 ratatui::layout::Rect::new(area.x, rows[2].y.saturating_sub(h), area.width, h);
2347 crate::components::which_key::render(f, rect, theme, &self.leader, &gateway);
2348 }
2349
2350 self.overlays.render(f, f.area(), &self.theme);
2352 }
2353
2354 async fn handle_app_message(&mut self, msg: AppEvent, tx: &AppTx) {
2355 match msg {
2356 AppEvent::OverlayData(data) => {
2361 self.overlays.handle_data(&data, &self.vault, tx);
2362 }
2363 AppEvent::Update(flow) => self.handle_update(flow, tx),
2364 AppEvent::FileOp(op) => self.handle_file_op(op, tx).await,
2365 AppEvent::SavedSearch(flow) => self.handle_saved_search(flow, tx),
2366 msg => {
2367 let Some(msg) = self.doc_meta.handle(msg, &self.path) else {
2370 return;
2371 };
2372 self.handle_owned_message(msg, tx).await;
2373 }
2374 }
2375 }
2376
2377 async fn try_open_path(
2380 &mut self,
2381 path: VaultPath,
2382 emphasis: Option<Vec<String>>,
2383 tx: &AppTx,
2384 ) -> Option<VaultPath> {
2385 self.dismiss_overlay();
2386 if path.is_note() {
2387 self.open_path(path, emphasis, tx).await;
2388 self.focus_editor();
2389 } else {
2390 self.navigate_sidebar(path, tx);
2391 }
2392 None
2393 }
2394
2395 async fn try_open_attachment(&mut self, path: VaultPath, tx: &AppTx) -> Option<VaultPath> {
2396 self.dismiss_overlay();
2397 self.open_attachment(path, tx).await;
2398 None
2399 }
2400
2401 async fn on_exit(&mut self, _tx: &AppTx) {
2402 self.try_save().await;
2403 }
2404}
2405
2406#[cfg(test)]
2407mod tests {
2408 use super::*;
2409 use crate::keys::key_event_to_combo;
2410
2411 #[test]
2413 fn panel_kind_labels_and_overlay_kind_compile() {
2414 assert_eq!(PanelKind::Editor.label(), "EDITOR");
2415 assert_eq!(PanelKind::Rail.label(), "RAIL");
2416 assert_eq!(DrawerView::Files.label(), "FILES");
2417 assert_eq!(DrawerView::Find.label(), "FIND");
2418 let _kind = OverlayKind::Dialog;
2419 }
2420
2421 async fn test_screen() -> (
2424 EditorScreen,
2425 Arc<NoteVault>,
2426 SharedSettings,
2427 tempfile::TempDir,
2428 ) {
2429 use crate::settings::AppSettings;
2430 use kimun_core::VaultConfig;
2431 use std::sync::RwLock;
2432
2433 let dir = tempfile::TempDir::new().unwrap();
2434 let vault = Arc::new(
2435 NoteVault::new(VaultConfig::new(crate::test_support::sys(dir.path())))
2436 .await
2437 .unwrap(),
2438 );
2439 let settings: SharedSettings = Arc::new(RwLock::new(AppSettings::default()));
2440 let screen = EditorScreen::new(vault.clone(), VaultPath::root(), settings.clone());
2441 (screen, vault, settings, dir)
2442 }
2443
2444 fn key_event(code: ratatui::crossterm::event::KeyCode) -> InputEvent {
2445 use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
2446 InputEvent::Key(KeyEvent::new(code, KeyModifiers::NONE))
2447 }
2448
2449 fn ctrl_key(c: char) -> InputEvent {
2450 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2451 InputEvent::Key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL))
2452 }
2453
2454 fn chr(c: char) -> InputEvent {
2455 key_event(ratatui::crossterm::event::KeyCode::Char(c))
2456 }
2457
2458 #[tokio::test]
2461 async fn leader_sequence_opens_drawer_view() {
2462 let (mut screen, _, _, _dir) = test_screen().await;
2463 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2464
2465 screen.panels.open_drawer_view(DrawerView::Tags);
2467
2468 screen.handle_input(&ctrl_key('b'), &tx);
2469 assert!(screen.leader.is_pending());
2470 screen.handle_input(&chr('o'), &tx);
2471 screen.handle_input(&chr('f'), &tx);
2472
2473 assert!(!screen.leader.is_pending());
2474 assert_eq!(screen.panels.active_drawer_view(), DrawerView::Files);
2475 assert_eq!(screen.panels.focused(), PanelKind::Drawer);
2476 }
2477
2478 #[tokio::test]
2483 async fn leader_formatting_yields_to_the_find_bar() {
2484 let (mut screen, _, _, _dir) = test_screen().await;
2485 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2486 screen
2487 .panels
2488 .editor_mut()
2489 .unwrap()
2490 .set_text("plain".to_string());
2491 screen.run_op(EditorOp::FindInBuffer, &tx);
2492 assert_eq!(
2493 screen.panels.editor().unwrap().claim(),
2494 crate::components::text_editor::EditorClaim::FindBar
2495 );
2496
2497 screen.handle_input(&ctrl_key('b'), &tx);
2498 screen.handle_input(&chr('t'), &tx);
2499 screen.handle_input(&chr('b'), &tx);
2500
2501 assert!(!screen.leader.is_pending());
2502 let text = screen.panels.editor().unwrap().get_text();
2503 assert!(
2504 !text.contains("**"),
2505 "the note was formatted under a claim: {text:?}"
2506 );
2507 }
2508
2509 #[tokio::test]
2512 async fn leader_formatting_applies_with_the_editor_focused() {
2513 let (mut screen, _, _, _dir) = test_screen().await;
2514 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2515 screen
2516 .panels
2517 .editor_mut()
2518 .unwrap()
2519 .set_text("plain".to_string());
2520
2521 screen.handle_input(&ctrl_key('b'), &tx);
2522 screen.handle_input(&chr('t'), &tx);
2523 screen.handle_input(&chr('b'), &tx);
2524
2525 let text = screen.panels.editor().unwrap().get_text();
2526 assert!(text.contains("**"), "no bold marker was inserted: {text:?}");
2527 }
2528
2529 #[tokio::test]
2532 async fn opening_files_drawer_reveals_current_note_dir() {
2533 let (mut screen, _, _, _dir) = test_screen().await;
2534 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2535
2536 screen.path = VaultPath::new("projects").append(&VaultPath::note_path_from("plan"));
2537 screen
2539 .panels
2540 .sidebar_mut()
2541 .navigate(VaultPath::new("other"), &tx);
2542 screen.panels.hide(PanelKind::Drawer);
2543
2544 screen.open_drawer_view(DrawerView::Files, &tx);
2545
2546 assert!(
2547 screen
2548 .panels
2549 .sidebar()
2550 .current_dir()
2551 .is_like(&VaultPath::new("projects"))
2552 );
2553 }
2554
2555 #[tokio::test]
2559 async fn file_browser_shortcut_rereveals_note_dir_when_already_open() {
2560 let (mut screen, _, _, _dir) = test_screen().await;
2561 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2562
2563 screen.path = VaultPath::new("projects").append(&VaultPath::note_path_from("plan"));
2564 screen.open_drawer_view(DrawerView::Files, &tx);
2565 screen
2567 .panels
2568 .sidebar_mut()
2569 .navigate(VaultPath::new("other"), &tx);
2570
2571 screen.handle_input(&ctrl_key('e'), &tx);
2572
2573 assert!(
2574 screen
2575 .panels
2576 .sidebar()
2577 .current_dir()
2578 .is_like(&VaultPath::new("projects"))
2579 );
2580 }
2581
2582 #[tokio::test]
2585 async fn leader_consumes_keys_while_editor_focused() {
2586 let (mut screen, _, _, _dir) = test_screen().await;
2587 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2588
2589 screen.panels.editor_mut().unwrap().set_text(String::new());
2590 assert_eq!(screen.panels.focused(), PanelKind::Editor);
2591
2592 screen.handle_input(&ctrl_key('b'), &tx);
2593 assert!(screen.leader.is_pending());
2594 screen.handle_input(&chr('w'), &tx);
2596 screen.handle_input(&chr('z'), &tx); assert_eq!(screen.panels.editor().unwrap().get_text(), "");
2598 assert!(!screen.panels.is_visible(PanelKind::Drawer));
2599 }
2600
2601 #[tokio::test]
2603 async fn leader_esc_cancels_and_focuses_editor() {
2604 let (mut screen, _, _, _dir) = test_screen().await;
2605 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2606
2607 screen.panels.focus(PanelKind::Rail);
2608 screen.handle_input(&ctrl_key('b'), &tx);
2609 screen.handle_input(&chr('f'), &tx);
2610 screen.handle_input(&key_event(ratatui::crossterm::event::KeyCode::Esc), &tx);
2611
2612 assert!(!screen.leader.is_pending());
2613 assert_eq!(screen.panels.focused(), PanelKind::Editor);
2614 }
2615
2616 async fn wait_for(
2618 rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
2619 pred: impl Fn(&AppEvent) -> bool,
2620 ) -> AppEvent {
2621 tokio::time::timeout(std::time::Duration::from_secs(2), async {
2622 loop {
2623 let e = rx.recv().await.expect("channel open");
2624 if pred(&e) {
2625 break e;
2626 }
2627 }
2628 })
2629 .await
2630 .expect("expected event")
2631 }
2632
2633 #[tokio::test(flavor = "multi_thread")]
2636 async fn leader_f_p_opens_the_pinned_notes_dialog() {
2637 let (mut screen, _, _, _dir) = test_screen().await;
2638 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2639 screen.handle_input(&ctrl_key('b'), &tx);
2640 screen.handle_input(&chr('f'), &tx);
2641 screen.handle_input(&chr('p'), &tx);
2642 assert!(screen.overlays.is_open());
2643 assert_eq!(
2644 screen.overlays.active_kind(),
2645 Some(crate::components::overlay::OverlayKind::Dialog)
2646 );
2647 }
2648
2649 #[tokio::test(flavor = "multi_thread")]
2652 async fn leader_m_i_toggles_the_pin_and_flashes() {
2653 let (mut screen, vault, _, _dir) = test_screen().await;
2654 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2655 let note = VaultPath::new("plan.md");
2656 vault.create_note(¬e, "hi").await.unwrap();
2657 screen.path = note.clone();
2658
2659 screen.handle_input(&ctrl_key('b'), &tx);
2660 screen.handle_input(&chr('m'), &tx);
2661 screen.handle_input(&chr('i'), &tx);
2662 let e = wait_for(&mut rx, |e| matches!(e, AppEvent::FlashMessage(_))).await;
2663 assert!(
2664 matches!(&e, AppEvent::FlashMessage(m) if m == "pinned as 1 of 9"),
2665 "{e:?}"
2666 );
2667 assert_eq!(
2669 vault.list_pinned_notes().await.unwrap(),
2670 vec![VaultPath::new("/plan.md")]
2671 );
2672
2673 screen.handle_input(&ctrl_key('b'), &tx);
2674 screen.handle_input(&chr('m'), &tx);
2675 screen.handle_input(&chr('i'), &tx);
2676 let e = wait_for(&mut rx, |e| matches!(e, AppEvent::FlashMessage(_))).await;
2677 assert!(
2678 matches!(&e, AppEvent::FlashMessage(m) if m == "unpinned"),
2679 "{e:?}"
2680 );
2681 assert!(vault.list_pinned_notes().await.unwrap().is_empty());
2682 }
2683
2684 #[tokio::test(flavor = "multi_thread")]
2687 async fn leader_m_i_refuses_a_tenth_pin() {
2688 let (mut screen, vault, _, _dir) = test_screen().await;
2689 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2690 for i in 0..kimun_core::PINNED_NOTES_CAP {
2691 let n = VaultPath::new(format!("n{i}.md"));
2692 vault.create_note(&n, "hi").await.unwrap();
2693 vault.toggle_pinned_note(&n).await.unwrap();
2694 }
2695 let extra = VaultPath::new("extra.md");
2696 vault.create_note(&extra, "hi").await.unwrap();
2697 screen.path = extra;
2698 screen.handle_input(&ctrl_key('b'), &tx);
2699 screen.handle_input(&chr('m'), &tx);
2700 screen.handle_input(&chr('i'), &tx);
2701 let e = wait_for(&mut rx, |e| matches!(e, AppEvent::FlashMessage(_))).await;
2702 assert!(
2703 matches!(&e, AppEvent::FlashMessage(m) if m == "already 9 pinned — unpin one first"),
2704 "{e:?}"
2705 );
2706 }
2707
2708 #[tokio::test(flavor = "multi_thread")]
2712 async fn leader_m_i_on_a_missing_note_flashes() {
2713 let (mut screen, vault, _, _dir) = test_screen().await;
2714 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2715 screen.path = VaultPath::new("gone.md");
2716 screen.handle_input(&ctrl_key('b'), &tx);
2717 screen.handle_input(&chr('m'), &tx);
2718 screen.handle_input(&chr('i'), &tx);
2719 let e = wait_for(&mut rx, |e| matches!(e, AppEvent::FlashMessage(_))).await;
2720 assert!(
2721 matches!(&e, AppEvent::FlashMessage(m) if m == "note not found: gone.md"),
2722 "{e:?}"
2723 );
2724 assert!(vault.list_pinned_notes().await.unwrap().is_empty());
2725 }
2726
2727 #[tokio::test(flavor = "multi_thread")]
2731 async fn leader_m_c_with_ask_shown_flashes_like_m_i() {
2732 let (mut screen, vault, _, _dir) = test_screen().await;
2733 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2734 let note = VaultPath::new("plan.md");
2735 vault.create_note(¬e, "hi").await.unwrap();
2736 screen.path = note;
2737 screen.open_drawer_view(DrawerView::Ask, &tx);
2738
2739 screen.handle_input(&ctrl_key('b'), &tx);
2740 screen.handle_input(&chr('m'), &tx);
2741 screen.handle_input(&chr('c'), &tx);
2742 let e = wait_for(&mut rx, |e| matches!(e, AppEvent::FlashMessage(_))).await;
2743 assert!(
2744 matches!(&e, AppEvent::FlashMessage(m) if m == "no note open"),
2745 "{e:?}"
2746 );
2747 }
2748
2749 #[tokio::test(flavor = "multi_thread")]
2752 async fn leader_m_i_with_no_note_open_flashes() {
2753 let (mut screen, _, _, _dir) = test_screen().await;
2754 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2755 screen.handle_input(&ctrl_key('b'), &tx);
2757 screen.handle_input(&chr('m'), &tx);
2758 screen.handle_input(&chr('i'), &tx);
2759 let e = wait_for(&mut rx, |e| matches!(e, AppEvent::FlashMessage(_))).await;
2760 assert!(
2761 matches!(&e, AppEvent::FlashMessage(m) if m == "no note open"),
2762 "{e:?}"
2763 );
2764 }
2765
2766 #[tokio::test(flavor = "multi_thread")]
2769 async fn leader_m_i_with_attachment_shown_flashes() {
2770 let (mut screen, vault, _, _dir) = test_screen().await;
2771 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2772 vault
2773 .save_attachment(&VaultPath::new("assets/diagram.png"), &[1, 2, 3])
2774 .await
2775 .unwrap();
2776 screen
2777 .try_open_attachment(VaultPath::new("assets/diagram.png"), &tx)
2778 .await;
2779 assert!(screen.panels.is_showing_attachment());
2780
2781 screen.handle_input(&ctrl_key('b'), &tx);
2782 screen.handle_input(&chr('m'), &tx);
2783 screen.handle_input(&chr('i'), &tx);
2784 let e = wait_for(&mut rx, |e| matches!(e, AppEvent::FlashMessage(_))).await;
2785 assert!(
2786 matches!(&e, AppEvent::FlashMessage(m) if m == "no note open"),
2787 "{e:?}"
2788 );
2789 }
2790
2791 #[tokio::test(flavor = "multi_thread")]
2795 async fn leader_m_i_with_ask_shown_flashes() {
2796 let (mut screen, vault, _, _dir) = test_screen().await;
2797 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2798 let note = VaultPath::new("plan.md");
2799 vault.create_note(¬e, "hi").await.unwrap();
2800 screen.path = note;
2801 screen.open_drawer_view(DrawerView::Ask, &tx);
2802 assert!(screen.panels.is_showing_ask());
2803
2804 screen.handle_input(&ctrl_key('b'), &tx);
2805 screen.handle_input(&chr('m'), &tx);
2806 screen.handle_input(&chr('i'), &tx);
2807 let e = wait_for(&mut rx, |e| matches!(e, AppEvent::FlashMessage(_))).await;
2808 assert!(
2809 matches!(&e, AppEvent::FlashMessage(m) if m == "no note open"),
2810 "{e:?}"
2811 );
2812 }
2813
2814 #[tokio::test(flavor = "multi_thread")]
2816 async fn leader_digit_opens_the_nth_pinned_note() {
2817 let (mut screen, vault, _, _dir) = test_screen().await;
2818 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2819 let a = VaultPath::new("a.md");
2820 let b = VaultPath::new("b.md");
2821 vault.create_note(&a, "a").await.unwrap();
2822 vault.create_note(&b, "b").await.unwrap();
2823 vault.toggle_pinned_note(&a).await.unwrap();
2824 vault.toggle_pinned_note(&b).await.unwrap();
2825
2826 screen.handle_input(&ctrl_key('b'), &tx);
2827 screen.handle_input(&chr('2'), &tx);
2828 let e = wait_for(&mut rx, |e| matches!(e, AppEvent::OpenPath { .. })).await;
2829 assert!(
2832 matches!(&e, AppEvent::OpenPath { path, .. } if path.is_like(&b)),
2833 "{e:?}"
2834 );
2835 }
2836
2837 #[tokio::test(flavor = "multi_thread")]
2839 async fn leader_digit_with_no_such_pin_flashes() {
2840 let (mut screen, _, _, _dir) = test_screen().await;
2841 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2842 screen.handle_input(&ctrl_key('b'), &tx);
2843 screen.handle_input(&chr('4'), &tx);
2844 let e = wait_for(&mut rx, |e| matches!(e, AppEvent::FlashMessage(_))).await;
2845 assert!(
2846 matches!(&e, AppEvent::FlashMessage(m) if m == "no pinned note 4"),
2847 "{e:?}"
2848 );
2849 }
2850
2851 #[tokio::test(flavor = "multi_thread")]
2854 async fn leader_digit_to_a_missing_pin_flashes() {
2855 let (mut screen, vault, _, dir) = test_screen().await;
2856 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2857 let gone = VaultPath::new("gone.md");
2858 vault.create_note(&gone, "hi").await.unwrap();
2859 vault.toggle_pinned_note(&gone).await.unwrap();
2860 std::fs::remove_file(dir.path().join("gone.md")).unwrap();
2862 screen.handle_input(&ctrl_key('b'), &tx);
2863 screen.handle_input(&chr('1'), &tx);
2864 let e = wait_for(&mut rx, |e| matches!(e, AppEvent::FlashMessage(_))).await;
2865 assert!(
2867 matches!(&e, AppEvent::FlashMessage(m) if m == "pinned note not found: /gone.md"),
2868 "{e:?}"
2869 );
2870 }
2871
2872 #[tokio::test]
2876 async fn space_never_leads() {
2877 let (mut screen, _, _, _dir) = test_screen().await;
2878 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2879
2880 screen.panels.editor_mut().unwrap().set_text(String::new());
2882 screen.handle_input(&chr(' '), &tx);
2883 assert!(!screen.leader.is_pending());
2884 assert_eq!(screen.panels.editor().unwrap().get_text(), " ");
2885
2886 screen.panels.focus(PanelKind::Rail);
2888 screen.handle_input(&chr(' '), &tx);
2889 assert!(!screen.leader.is_pending());
2890
2891 screen.panels.open_drawer_view(DrawerView::Find);
2893 screen.panels.focus(PanelKind::Drawer);
2894 screen.handle_input(&chr(' '), &tx);
2895 assert!(!screen.leader.is_pending());
2896 }
2897
2898 #[tokio::test]
2899 async fn persist_saved_search_writes_via_core() {
2900 let (screen, _, _, _dir) = test_screen().await;
2901
2902 screen.persist_saved_search("t", "#todo").await.unwrap();
2903
2904 let all = screen.vault.list_saved_searches().await.unwrap();
2905 assert!(all.iter().any(|s| s.name == "t" && s.query == "#todo"));
2906 }
2907
2908 #[tokio::test]
2909 async fn applying_saved_search_sets_panel_query_and_focuses_it() {
2910 let (mut screen, _, _, _dir) = test_screen().await;
2911
2912 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2913 screen.apply_saved_search(
2914 "<{note}".to_string(),
2915 "Backlinks (current note)".to_string(),
2916 &tx,
2917 );
2918 assert!(screen.panels.is_visible(PanelKind::Drawer));
2919 assert_eq!(screen.panels.active_drawer_view(), DrawerView::Find);
2920 assert_eq!(screen.panels.query().active_query(), "<{note}");
2921 assert_eq!(screen.panels.focused(), PanelKind::Drawer);
2922 }
2923
2924 #[tokio::test]
2925 async fn saved_search_persisted_repins_panel_breadcrumb() {
2926 let (mut screen, _, _, _dir) = test_screen().await;
2927
2928 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2929 screen.apply_saved_search("#todo".to_string(), "todo".to_string(), &tx);
2930 screen
2931 .panels
2932 .query_mut()
2933 .set_active_query("#todo and #urgent".to_string());
2934
2935 screen
2936 .handle_app_message(
2937 AppEvent::SavedSearch(SavedSearchFlow::Persisted {
2938 name: "urgent-todos".to_string(),
2939 query: "#todo and #urgent".to_string(),
2940 source: SaveSource::QueryPanel,
2941 }),
2942 &tx,
2943 )
2944 .await;
2945
2946 assert_eq!(
2947 screen.panels.query().saved_search_breadcrumb().as_deref(),
2948 Some("urgent-todos"),
2949 "a persisted panel-sourced save re-pins the breadcrumb"
2950 );
2951 }
2952
2953 #[tokio::test]
2954 async fn persisted_note_browser_save_does_not_repin_even_on_equal_query() {
2955 let (mut screen, _, _, _dir) = test_screen().await;
2956
2957 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2958 screen.apply_saved_search("#todo".to_string(), "todo".to_string(), &tx);
2959
2960 screen
2963 .handle_app_message(
2964 AppEvent::SavedSearch(SavedSearchFlow::Persisted {
2965 name: "inbox".to_string(),
2966 query: "#todo".to_string(),
2967 source: SaveSource::NoteBrowser,
2968 }),
2969 &tx,
2970 )
2971 .await;
2972
2973 assert_eq!(
2974 screen.panels.query().saved_search_breadcrumb().as_deref(),
2975 Some("todo"),
2976 "a note-browser save must not steal the panel's provenance"
2977 );
2978 }
2979
2980 #[tokio::test]
2981 async fn persisted_query_as_name_save_skips_repin() {
2982 let (mut screen, _, _, _dir) = test_screen().await;
2983
2984 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2985 screen.apply_saved_search("#todo".to_string(), "todo".to_string(), &tx);
2986
2987 screen
2991 .handle_app_message(
2992 AppEvent::SavedSearch(SavedSearchFlow::Persisted {
2993 name: "#todo".to_string(),
2994 query: "#todo".to_string(),
2995 source: SaveSource::QueryPanel,
2996 }),
2997 &tx,
2998 )
2999 .await;
3000
3001 assert_eq!(
3002 screen.panels.query().saved_search_breadcrumb().as_deref(),
3003 Some("todo"),
3004 "a query-as-name save leaves the breadcrumb alone"
3005 );
3006 }
3007
3008 #[tokio::test]
3009 async fn save_search_confirmed_persists_then_emits_persisted() {
3010 let (mut screen, vault, _, _dir) = test_screen().await;
3011
3012 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3013 screen
3014 .handle_app_message(
3015 AppEvent::SavedSearch(SavedSearchFlow::Confirmed {
3016 name: "mine".to_string(),
3017 query: "#todo".to_string(),
3018 source: SaveSource::QueryPanel,
3019 }),
3020 &tx,
3021 )
3022 .await;
3023
3024 let event = tokio::time::timeout(std::time::Duration::from_secs(5), async {
3026 loop {
3027 match rx.recv().await {
3028 Some(e @ AppEvent::SavedSearch(SavedSearchFlow::Persisted { .. })) => break e,
3029 Some(_) => continue,
3030 None => panic!("channel closed before SavedSearchPersisted"),
3031 }
3032 }
3033 })
3034 .await
3035 .expect("SavedSearchPersisted within timeout");
3036
3037 let AppEvent::SavedSearch(SavedSearchFlow::Persisted {
3038 name,
3039 query,
3040 source,
3041 }) = event
3042 else {
3043 unreachable!()
3044 };
3045 assert_eq!((name.as_str(), query.as_str()), ("mine", "#todo"));
3046 assert_eq!(source, SaveSource::QueryPanel);
3047 let all = vault.list_saved_searches().await.unwrap();
3048 assert!(all.iter().any(|s| s.name == "mine" && s.query == "#todo"));
3049 }
3050
3051 #[tokio::test]
3052 async fn save_query_source_carries_panel_provenance() {
3053 let (mut screen, _, _, _dir) = test_screen().await;
3054
3055 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3056 screen.apply_saved_search("#todo".to_string(), "todo".to_string(), &tx);
3057
3058 assert_eq!(
3059 screen.save_query_source(),
3060 Some((
3061 "#todo".to_string(),
3062 Some("todo".to_string()),
3063 SaveSource::QueryPanel
3064 )),
3065 "the save dialog opens pre-filled with the breadcrumb provenance"
3066 );
3067 }
3068
3069 #[tokio::test]
3076 async fn saved_search_selected_then_close_overlay_keeps_backlinks_focus() {
3077 use crate::settings::AppSettings;
3078 use kimun_core::VaultConfig;
3079 use std::sync::RwLock;
3080
3081 let dir = tempfile::TempDir::new().unwrap();
3082 let vault = Arc::new(
3083 NoteVault::new(VaultConfig::new(crate::test_support::sys(dir.path())))
3084 .await
3085 .unwrap(),
3086 );
3087 let settings: SharedSettings = Arc::new(RwLock::new(AppSettings::default()));
3088 let mut screen = EditorScreen::new(vault, VaultPath::root(), settings);
3089
3090 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3091 screen
3093 .handle_app_message(
3094 AppEvent::SavedSearch(SavedSearchFlow::Selected {
3095 query: "<{note}".to_string(),
3096 name: "Backlinks (current note)".to_string(),
3097 }),
3098 &tx,
3099 )
3100 .await;
3101 screen.handle_app_message(AppEvent::CloseOverlay, &tx).await;
3102
3103 assert!(
3104 screen.panels.focused() == PanelKind::Drawer
3105 && screen.panels.active_drawer_view() == DrawerView::Find,
3106 "focus should remain on the FIND drawer after select + close"
3107 );
3108 assert!(!screen.overlays.is_open(), "overlay should be closed");
3109 }
3110
3111 #[tokio::test]
3116 async fn opener_action_does_not_replace_open_overlay() {
3117 use crate::settings::AppSettings;
3118 use kimun_core::VaultConfig;
3119 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
3120 use std::sync::RwLock;
3121
3122 let dir = tempfile::TempDir::new().unwrap();
3123 let vault = Arc::new(
3124 NoteVault::new(VaultConfig::new(crate::test_support::sys(dir.path())))
3125 .await
3126 .unwrap(),
3127 );
3128 let settings: SharedSettings = Arc::new(RwLock::new(AppSettings::default()));
3129 let mut screen = EditorScreen::new(vault.clone(), VaultPath::root(), settings.clone());
3130
3131 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3132
3133 {
3136 let s = settings.read().unwrap();
3137 let modal = SavedSearchesModal::new(
3138 vault.clone(),
3139 s.key_bindings.clone(),
3140 s.icons(),
3141 tx.clone(),
3142 );
3143 drop(s);
3144 screen.overlays.open(Box::new(modal), screen.opener_focus());
3145 }
3146 assert_eq!(
3147 screen.overlays.active_kind(),
3148 Some(OverlayKind::SavedSearches),
3149 "precondition: SavedSearches overlay is active"
3150 );
3151
3152 let quick_note_event = KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL);
3156 {
3157 let s = settings.read().unwrap();
3158 let combo = key_event_to_combo(&quick_note_event).expect("Ctrl+W maps to a combo");
3159 assert_eq!(
3160 s.key_bindings.get_action(&combo),
3161 Some(ActionShortcuts::QuickNote),
3162 "test assumes Ctrl+W is bound to QuickNote"
3163 );
3164 }
3165
3166 screen.handle_input(&InputEvent::Key(quick_note_event), &tx);
3167
3168 assert_eq!(
3171 screen.overlays.active_kind(),
3172 Some(OverlayKind::SavedSearches),
3173 "open overlay must not be replaced by a QuickNote opener action"
3174 );
3175 }
3176
3177 #[tokio::test]
3181 async fn focus_action_is_noop_while_overlay_open() {
3182 use crate::settings::AppSettings;
3183 use kimun_core::VaultConfig;
3184 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
3185 use std::sync::RwLock;
3186
3187 let dir = tempfile::TempDir::new().unwrap();
3188 let vault = Arc::new(
3189 NoteVault::new(VaultConfig::new(crate::test_support::sys(dir.path())))
3190 .await
3191 .unwrap(),
3192 );
3193 let settings: SharedSettings = Arc::new(RwLock::new(AppSettings::default()));
3194 let mut screen = EditorScreen::new(vault.clone(), VaultPath::root(), settings.clone());
3195
3196 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3197
3198 {
3200 let s = settings.read().unwrap();
3201 let modal = SavedSearchesModal::new(
3202 vault.clone(),
3203 s.key_bindings.clone(),
3204 s.icons(),
3205 tx.clone(),
3206 );
3207 drop(s);
3208 screen.overlays.open(Box::new(modal), screen.opener_focus());
3209 }
3210 assert_ne!(screen.panels.active_drawer_view(), DrawerView::Find);
3211 let focused_before = screen.panels.focused();
3212
3213 let focus_right = KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL);
3215 {
3216 let s = settings.read().unwrap();
3217 let combo = key_event_to_combo(&focus_right).expect("Ctrl+L maps to a combo");
3218 assert_eq!(
3219 s.key_bindings.get_action(&combo),
3220 Some(ActionShortcuts::FocusEditor),
3221 "test assumes Ctrl+L is bound to FocusEditor"
3222 );
3223 }
3224 screen.handle_input(&InputEvent::Key(focus_right), &tx);
3225
3226 assert_eq!(
3227 screen.panels.focused(),
3228 focused_before,
3229 "focus action must not move focus while an overlay is open"
3230 );
3231 assert_ne!(
3232 screen.panels.active_drawer_view(),
3233 DrawerView::Find,
3234 "focus action must not switch the drawer view while an overlay is open"
3235 );
3236 assert_eq!(
3237 screen.overlays.active_kind(),
3238 Some(OverlayKind::SavedSearches),
3239 "overlay stays active"
3240 );
3241 }
3242
3243 #[tokio::test(flavor = "multi_thread")]
3248 async fn open_journal_dismisses_open_overlay() {
3249 let vault = crate::test_support::temp_vault("editor-journal").await;
3250 vault.validate_and_init().await.unwrap();
3251 let settings = std::sync::Arc::new(std::sync::RwLock::new(
3252 crate::settings::AppSettings::default(),
3253 ));
3254 let mut screen = EditorScreen::new(vault.clone(), VaultPath::root(), settings.clone());
3255 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3256
3257 {
3258 let s = settings.read().unwrap();
3259 let modal = SavedSearchesModal::new(
3260 vault.clone(),
3261 s.key_bindings.clone(),
3262 s.icons(),
3263 tx.clone(),
3264 );
3265 drop(s);
3266 screen.present_overlay(Box::new(modal));
3267 }
3268 assert!(screen.overlays.is_open(), "precondition: overlay open");
3269
3270 let (details, _, _) = vault.journal_entry().await.unwrap();
3271 screen.try_open_path(details.path, None, &tx).await;
3272
3273 assert!(
3274 !screen.overlays.is_open(),
3275 "opening the journal must dismiss the overlay before loading the note"
3276 );
3277 }
3278
3279 #[tokio::test(flavor = "multi_thread")]
3283 async fn opening_attachment_shows_attachment_view() {
3284 let vault = crate::test_support::temp_vault("editor-attachment").await;
3285 vault.validate_and_init().await.unwrap();
3286 vault
3287 .save_attachment(&VaultPath::new("assets/diagram.png"), &[1, 2, 3])
3288 .await
3289 .unwrap();
3290 let settings = std::sync::Arc::new(std::sync::RwLock::new(
3291 crate::settings::AppSettings::default(),
3292 ));
3293 let mut screen = EditorScreen::new(vault.clone(), VaultPath::root(), settings);
3294 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3295
3296 assert!(screen.panels.editor().is_some());
3298
3299 screen
3300 .try_open_attachment(VaultPath::new("assets/diagram.png"), &tx)
3301 .await;
3302
3303 assert!(
3304 screen.panels.is_showing_attachment(),
3305 "the editor area shows the attachment view"
3306 );
3307 assert!(
3308 screen.panels.editor().is_none(),
3309 "no note editor is mounted while an attachment is shown"
3310 );
3311 assert_eq!(
3312 screen.panels.attachment_path(),
3313 Some(&VaultPath::new("assets/diagram.png"))
3314 );
3315
3316 vault
3318 .create_note(&VaultPath::new("note.md"), "hi")
3319 .await
3320 .unwrap();
3321 screen.open_path(VaultPath::new("note.md"), None, &tx).await;
3322 assert!(!screen.panels.is_showing_attachment());
3323 assert!(screen.panels.editor().is_some());
3324 }
3325
3326 #[tokio::test]
3327 async fn save_query_from_note_browser_opens_save_dialog() {
3328 use crate::settings::AppSettings;
3329 use kimun_core::VaultConfig;
3330 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
3331 use std::sync::RwLock;
3332
3333 let dir = tempfile::TempDir::new().unwrap();
3334 let vault = Arc::new(
3335 NoteVault::new(VaultConfig::new(crate::test_support::sys(dir.path())))
3336 .await
3337 .unwrap(),
3338 );
3339 let settings: SharedSettings = Arc::new(RwLock::new(AppSettings::default()));
3340 let mut screen = EditorScreen::new(vault.clone(), VaultPath::root(), settings.clone());
3341
3342 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3343
3344 {
3346 let s = settings.read().unwrap();
3347 let provider = resolving_search_source(vault.clone(), s.current_last_paths(), None);
3348 let modal = NoteBrowserModal::with_initial_query(
3349 "Note Browser",
3350 BrowserScope::Query,
3351 provider,
3352 vault.clone(),
3353 s.key_bindings.clone(),
3354 s.icons(),
3355 tx.clone(),
3356 "#todo",
3357 );
3358 drop(s);
3359 screen.overlays.open(Box::new(modal), screen.opener_focus());
3360 }
3361 assert_eq!(
3362 screen.overlays.active_kind(),
3363 Some(OverlayKind::NoteBrowser),
3364 "precondition: note browser is active with a query"
3365 );
3366 assert_eq!(screen.overlays.active_query(), Some("#todo"));
3367
3368 let save_event = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL);
3371 {
3372 let s = settings.read().unwrap();
3373 let combo = key_event_to_combo(&save_event).expect("Ctrl+D maps to a combo");
3374 assert_eq!(
3375 s.key_bindings.get_action(&combo),
3376 Some(ActionShortcuts::SaveCurrentQuery),
3377 "test assumes Ctrl+D is bound to SaveCurrentQuery"
3378 );
3379 }
3380
3381 screen.handle_input(&InputEvent::Key(save_event), &tx);
3382
3383 assert_eq!(
3384 screen.overlays.active_kind(),
3385 Some(OverlayKind::Dialog),
3386 "saving from the note browser opens the save-search dialog"
3387 );
3388 }
3389
3390 #[tokio::test]
3393 async fn opening_a_missing_note_leaves_the_history_alone() {
3394 use crate::settings::AppSettings;
3395 use std::sync::RwLock;
3396
3397 let vault = crate::test_support::temp_vault("editor-missing").await;
3398 vault
3399 .create_note(&VaultPath::new("here.md"), "text")
3400 .await
3401 .unwrap();
3402 let history_dir = tempfile::TempDir::new().unwrap();
3403 let settings: SharedSettings = Arc::new(RwLock::new(AppSettings::for_test_workspace(
3404 "ws",
3405 vault.workspace_path(),
3406 crate::test_support::sys(history_dir.path()),
3407 )));
3408 let mut screen = EditorScreen::new(vault, VaultPath::root(), settings.clone());
3409 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3410
3411 screen.open_path(VaultPath::new("here.md"), None, &tx).await;
3412 screen
3413 .open_path(VaultPath::new("missing.md"), None, &tx)
3414 .await;
3415
3416 assert_eq!(
3417 settings.read().unwrap().current_last_paths(),
3418 vec![VaultPath::new("here.md")]
3419 );
3420 assert_eq!(
3421 screen.overlays.active_kind(),
3422 Some(OverlayKind::Dialog),
3423 "the create-note offer still stands for an explicit open"
3424 );
3425 }
3426
3427 #[tokio::test(flavor = "multi_thread")]
3430 async fn renaming_open_note_retargets_in_place() {
3431 let vault = crate::test_support::temp_vault("editor-rename").await;
3432 vault.validate_and_init().await.unwrap();
3433 let from = VaultPath::note_path_from("old");
3434 vault.create_note(&from, "# Old\n\nbody").await.unwrap();
3435 let settings = std::sync::Arc::new(std::sync::RwLock::new(
3436 crate::settings::AppSettings::default(),
3437 ));
3438 let mut screen = EditorScreen::new(vault.clone(), from.clone(), settings);
3439 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3440 screen.on_enter(&tx).await;
3441
3442 let to = VaultPath::note_path_from("new");
3443 vault.rename_note(&from, &to).await.unwrap();
3444 screen
3445 .handle_app_message(
3446 AppEvent::FileOp(FileOp::Renamed {
3447 from: from.clone(),
3448 to: to.clone(),
3449 }),
3450 &tx,
3451 )
3452 .await;
3453
3454 assert_eq!(screen.path, to, "editor retargets to the new path");
3455 assert!(
3456 !screen.panels.editor().unwrap().is_dirty(),
3457 "reloaded buffer is clean (won't clobber the renamed file)"
3458 );
3459 }
3460
3461 #[tokio::test(flavor = "multi_thread")]
3464 async fn open_then_save_marks_and_retitles_sidebar_row() {
3465 let vault = crate::test_support::temp_vault("editor-marksave").await;
3466 vault.validate_and_init().await.unwrap();
3467 vault
3468 .create_note(&VaultPath::note_path_from("alpha"), "# Alpha\n\nbody")
3469 .await
3470 .unwrap();
3471 let settings = std::sync::Arc::new(std::sync::RwLock::new(
3472 crate::settings::AppSettings::default(),
3473 ));
3474 let path = VaultPath::note_path_from("alpha");
3475 let mut screen = EditorScreen::new(vault.clone(), path.clone(), settings);
3476 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3477 screen.on_enter(&tx).await;
3478 for _ in 0..50 {
3479 screen.panels.sidebar_mut().poll_for_test();
3480 if !screen.panels.sidebar().is_loading_for_test() {
3481 break;
3482 }
3483 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
3484 }
3485
3486 assert!(
3487 screen
3488 .panels
3489 .sidebar()
3490 .note_row_is_open_for_test("alpha.md"),
3491 "the open note's row is marked"
3492 );
3493
3494 screen
3495 .handle_app_message(
3496 AppEvent::AutosaveCompleted {
3497 path: path.clone(),
3498 saved_revision: None,
3499 title: Some("New First Line".to_string()),
3500 },
3501 &tx,
3502 )
3503 .await;
3504
3505 assert_eq!(
3506 screen.panels.sidebar().note_row_title_for_test("alpha.md"),
3507 Some("New First Line".to_string()),
3508 "the saved note's row title updated in place"
3509 );
3510 }
3511
3512 #[tokio::test]
3516 async fn ask_drawer_switch_preserves_thread() {
3517 let (mut screen, _, _, _dir) = test_screen().await;
3518 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3519
3520 screen.open_drawer_view(DrawerView::Ask, &tx);
3522 assert!(screen.panels.is_showing_ask());
3523 {
3524 let panel = screen.panels.ask_mut();
3525 panel.thread_mut().ask("first?".into());
3526 panel.thread_mut().ask("second?".into());
3527 }
3528 assert_eq!(screen.panels.ask().thread().turns().len(), 2);
3529
3530 screen.open_drawer_view(DrawerView::Files, &tx);
3533 assert!(!screen.panels.is_showing_ask());
3534 assert_eq!(screen.panels.ask().thread().turns().len(), 2);
3535
3536 screen.open_drawer_view(DrawerView::Ask, &tx);
3538 assert!(screen.panels.is_showing_ask());
3539 assert_eq!(
3540 screen.panels.ask().thread().turns().len(),
3541 2,
3542 "the conversation survives the round trip"
3543 );
3544 }
3545
3546 #[tokio::test]
3552 async fn ask_answer_ready_reaches_the_thread_via_the_owned_message_path() {
3553 let (mut screen, _, _, _dir) = test_screen().await;
3554 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3555
3556 screen.open_drawer_view(DrawerView::Ask, &tx);
3557 let id = screen.panels.ask_mut().thread_mut().ask("q?".into());
3558
3559 screen
3560 .handle_app_message(
3561 AppEvent::Ask(crate::components::events::AskData::AnswerReady {
3562 turn_id: id,
3563 result: Ok(("the answer".into(), vec![])),
3564 }),
3565 &tx,
3566 )
3567 .await;
3568
3569 let panel = screen.panels.ask_mut();
3570 let turn = panel.thread().selected().expect("a turn is selected");
3571 assert!(matches!(turn.status, crate::ask::TurnStatus::Done));
3572 assert_eq!(turn.answer, "the answer");
3573 }
3574
3575 #[tokio::test]
3580 async fn opening_a_note_over_ask_shows_the_note_and_keeps_the_thread() {
3581 let vault = crate::test_support::temp_vault("editor-ask-open").await;
3582 vault.validate_and_init().await.unwrap();
3583 vault
3584 .create_note(&VaultPath::note_path_from("alpha"), "# Alpha\n\nbody")
3585 .await
3586 .unwrap();
3587 let settings = std::sync::Arc::new(std::sync::RwLock::new(
3588 crate::settings::AppSettings::default(),
3589 ));
3590 let mut screen = EditorScreen::new(vault, VaultPath::root(), settings);
3591 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3592
3593 screen.open_drawer_view(DrawerView::Ask, &tx);
3594 screen
3595 .panels
3596 .ask_mut()
3597 .thread_mut()
3598 .ask("a question?".into());
3599 assert!(screen.panels.is_showing_ask());
3600
3601 screen
3603 .open_path(VaultPath::note_path_from("alpha"), None, &tx)
3604 .await;
3605 assert!(!screen.panels.is_showing_ask(), "the note replaced Ask");
3606 assert!(screen.panels.editor().is_some(), "the note editor is shown");
3607 assert_eq!(
3608 screen.panels.active_drawer_view(),
3609 DrawerView::Files,
3610 "the Sources drawer must not linger on a hidden conversation"
3611 );
3612
3613 screen.open_drawer_view(DrawerView::Ask, &tx);
3615 assert_eq!(
3616 screen.panels.ask().thread().turns().len(),
3617 1,
3618 "the thread survived opening the note"
3619 );
3620 }
3621
3622 #[tokio::test]
3628 async fn opening_a_note_over_ask_with_hidden_drawer_keeps_it_hidden() {
3629 let vault = crate::test_support::temp_vault("editor-ask-hidden-drawer").await;
3630 vault.validate_and_init().await.unwrap();
3631 vault
3632 .create_note(&VaultPath::note_path_from("alpha"), "# Alpha\n\nbody")
3633 .await
3634 .unwrap();
3635 let settings = std::sync::Arc::new(std::sync::RwLock::new(
3636 crate::settings::AppSettings::default(),
3637 ));
3638 let mut screen = EditorScreen::new(vault, VaultPath::root(), settings);
3639 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3640
3641 screen.open_drawer_view(DrawerView::Ask, &tx);
3643 screen.panels.hide(PanelKind::Drawer);
3644 assert!(!screen.panels.is_visible(PanelKind::Drawer));
3645 assert!(screen.panels.is_showing_ask());
3646
3647 screen
3650 .open_path(VaultPath::note_path_from("alpha"), None, &tx)
3651 .await;
3652 assert!(!screen.panels.is_showing_ask(), "the note replaced Ask");
3653 assert!(
3654 !screen.panels.is_visible(PanelKind::Drawer),
3655 "a hidden drawer must not be force-revealed"
3656 );
3657 assert_eq!(
3658 screen.panels.active_drawer_view(),
3659 DrawerView::Files,
3660 "the view switched off Ask to FILES"
3661 );
3662 }
3663
3664 #[tokio::test]
3672 async fn rail_click_reenters_ask_in_one_click_after_a_note_stashed_it() {
3673 let vault = crate::test_support::temp_vault("editor-ask-rail-reentry").await;
3674 vault.validate_and_init().await.unwrap();
3675 vault
3676 .create_note(&VaultPath::note_path_from("alpha"), "# Alpha\n\nbody")
3677 .await
3678 .unwrap();
3679 let settings = std::sync::Arc::new(std::sync::RwLock::new(
3680 crate::settings::AppSettings::default(),
3681 ));
3682 let mut screen = EditorScreen::new(vault, VaultPath::root(), settings);
3683 screen.rag_status = crate::rag::RagStatus::Online {
3684 llm_available: true,
3685 };
3686 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3687
3688 screen
3689 .handle_app_message(AppEvent::OpenDrawerView(DrawerView::Ask), &tx)
3690 .await;
3691 assert!(screen.panels.is_showing_ask());
3692 screen
3693 .panels
3694 .ask_mut()
3695 .thread_mut()
3696 .ask("a question?".into());
3697
3698 screen
3699 .open_path(VaultPath::note_path_from("alpha"), None, &tx)
3700 .await;
3701 assert!(!screen.panels.is_showing_ask());
3702
3703 screen
3705 .handle_app_message(AppEvent::OpenDrawerView(DrawerView::Ask), &tx)
3706 .await;
3707 assert!(
3708 screen.panels.is_showing_ask(),
3709 "a single reopen must not read as a toggle-off"
3710 );
3711 assert_eq!(
3712 screen.panels.ask().thread().turns().len(),
3713 1,
3714 "the thread survived the round trip"
3715 );
3716 }
3717
3718 #[tokio::test]
3724 async fn opening_an_attachment_over_ask_shows_it_and_keeps_the_thread() {
3725 let vault = crate::test_support::temp_vault("editor-ask-open-att").await;
3726 vault.validate_and_init().await.unwrap();
3727 let att_path = VaultPath::new("pic.png");
3728 vault
3729 .save_attachment(&att_path, b"\x89PNG\r\n\x1a\n")
3730 .await
3731 .unwrap();
3732 let settings = std::sync::Arc::new(std::sync::RwLock::new(
3733 crate::settings::AppSettings::default(),
3734 ));
3735 let mut screen = EditorScreen::new(vault, VaultPath::root(), settings);
3736 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3737
3738 screen.open_drawer_view(DrawerView::Ask, &tx);
3739 screen
3740 .panels
3741 .ask_mut()
3742 .thread_mut()
3743 .ask("a question?".into());
3744 assert!(screen.panels.is_showing_ask());
3745
3746 screen.open_attachment(att_path, &tx).await;
3748 assert!(
3749 !screen.panels.is_showing_ask(),
3750 "the attachment replaced Ask"
3751 );
3752
3753 screen.open_drawer_view(DrawerView::Ask, &tx);
3755 assert_eq!(
3756 screen.panels.ask().thread().turns().len(),
3757 1,
3758 "the thread survived opening the attachment"
3759 );
3760 }
3761
3762 #[tokio::test]
3766 async fn refresh_ask_capability_disables_without_a_client() {
3767 let (mut screen, _, _, _dir) = test_screen().await;
3768
3769 screen
3771 .ask
3772 .refresh_capability(&mut screen.panels, screen.rag_status)
3773 .await;
3774 assert!(
3775 !screen.panels.ask().has_client(),
3776 "the resident Ask panel loses its client without an LLM server"
3777 );
3778 }
3779
3780 #[tokio::test]
3783 async fn answer_lands_on_the_thread_while_browsing_files() {
3784 let (mut screen, _, _, _dir) = test_screen().await;
3785 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3786
3787 screen.open_drawer_view(DrawerView::Ask, &tx);
3788 let turn_id = screen.panels.ask_mut().thread_mut().ask("q?".into());
3789 screen.open_drawer_view(DrawerView::Files, &tx);
3791 assert!(!screen.panels.is_showing_ask());
3792
3793 screen.ask.handle_data(
3794 &mut screen.panels,
3795 crate::components::events::AskData::AnswerReady {
3796 turn_id,
3797 result: Ok(("the answer".into(), vec![])),
3798 },
3799 &tx,
3800 );
3801
3802 let thread = screen.panels.ask().thread();
3803 let turn = thread.turns().iter().find(|t| t.id == turn_id).unwrap();
3804 assert!(
3805 matches!(turn.status, crate::ask::TurnStatus::Done),
3806 "the answer completed the turn"
3807 );
3808 }
3809
3810 #[tokio::test]
3815 async fn sem_rail_entry_tracks_search_availability() {
3816 let (mut screen, _, _, _dir) = test_screen().await;
3817 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3818
3819 assert!(!screen.panels.rail_shows(DrawerView::Semantic));
3821 assert!(!screen.panels.rail_shows(DrawerView::Ask));
3822
3823 screen
3825 .handle_owned_message(
3826 AppEvent::RagStatus(crate::rag::RagStatus::Online {
3827 llm_available: false,
3828 }),
3829 &tx,
3830 )
3831 .await;
3832 assert!(screen.panels.rail_shows(DrawerView::Semantic));
3833 assert!(!screen.panels.rail_shows(DrawerView::Ask));
3834
3835 screen
3837 .handle_owned_message(AppEvent::RagStatus(crate::rag::RagStatus::Offline), &tx)
3838 .await;
3839 assert!(!screen.panels.rail_shows(DrawerView::Semantic));
3840
3841 screen
3843 .handle_owned_message(
3844 AppEvent::RagStatus(crate::rag::RagStatus::Online {
3845 llm_available: true,
3846 }),
3847 &tx,
3848 )
3849 .await;
3850 assert!(screen.panels.rail_shows(DrawerView::Semantic));
3851 assert!(screen.panels.rail_shows(DrawerView::Ask));
3852 }
3853
3854 #[tokio::test]
3860 async fn ask_focus_skips_the_llm_gate_when_already_showing_ask() {
3861 let (mut screen, _, _, _dir) = test_screen().await;
3862 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3863
3864 screen.rag_status = crate::rag::RagStatus::Online {
3865 llm_available: true,
3866 };
3867 screen.open_ask_workspace(&tx);
3868 assert!(screen.panels.is_showing_ask());
3869
3870 screen.rag_status = crate::rag::RagStatus::Offline;
3873
3874 screen.open_ask_workspace(&tx);
3875 assert!(screen.panels.is_showing_ask(), "still showing Ask");
3876 assert_eq!(screen.panels.focused(), PanelKind::Editor);
3877 assert!(
3878 rx.try_recv().is_err(),
3879 "no gate flash while Ask is already showing"
3880 );
3881 }
3882
3883 fn lay_out(screen: &mut EditorScreen) {
3895 let mut term = ratatui::Terminal::new(ratatui::backend::TestBackend::new(120, 40)).unwrap();
3896 term.draw(|f| screen.render(f)).unwrap();
3897 }
3898
3899 fn buffer_origin(screen: &EditorScreen) -> (u16, u16) {
3902 (0..40u16)
3903 .flat_map(|row| (0..120u16).map(move |col| (col, row)))
3904 .find(|(col, row)| screen.panels.is_note_buffer_cell(*col, *row))
3905 .expect("a laid-out screen always shows the note buffer")
3906 }
3907
3908 fn frame_corner(screen: &mut EditorScreen, tx: &AppTx) -> (u16, u16) {
3919 let (col, row) = buffer_origin(screen);
3920 let corner = (col - 1, row - 1);
3921 screen.panels.focus(PanelKind::Drawer);
3922 screen.handle_input(&press_at(corner.0, corner.1), tx);
3923 assert_eq!(
3924 screen.panels.focused(),
3925 PanelKind::Editor,
3926 "precondition: the frame is inside the editor column"
3927 );
3928 screen.clicks.end();
3930 corner
3931 }
3932
3933 fn press_at(col: u16, row: u16) -> InputEvent {
3934 use ratatui::crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
3935 InputEvent::Mouse(MouseEvent {
3936 kind: MouseEventKind::Down(MouseButton::Left),
3937 column: col,
3938 row,
3939 modifiers: KeyModifiers::NONE,
3940 })
3941 }
3942
3943 fn moved_at(col: u16, row: u16) -> InputEvent {
3944 use ratatui::crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
3945 InputEvent::Mouse(MouseEvent {
3946 kind: MouseEventKind::Moved,
3947 column: col,
3948 row,
3949 modifiers: KeyModifiers::NONE,
3950 })
3951 }
3952
3953 fn at(millis: u64) -> std::time::Instant {
3955 static ORIGIN: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
3956 *ORIGIN.get_or_init(std::time::Instant::now) + std::time::Duration::from_millis(millis)
3957 }
3958
3959 #[tokio::test]
3960 async fn two_presses_on_the_buffer_are_a_double_click() {
3961 let (mut screen, _, _, _dir) = test_screen().await;
3962 lay_out(&mut screen);
3963 let (col, row) = buffer_origin(&screen);
3964
3965 assert!(!screen.track_click(&press_at(col, row), at(0)));
3966 assert!(screen.track_click(&press_at(col, row), at(120)));
3967 }
3968
3969 #[tokio::test]
3975 async fn a_press_on_the_editor_frame_is_never_half_of_a_double() {
3976 let (mut screen, _, _, _dir) = test_screen().await;
3977 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3978 lay_out(&mut screen);
3979 let (col, row) = frame_corner(&mut screen, &tx);
3980
3981 assert!(!screen.track_click(&press_at(col, row), at(0)));
3982 assert!(
3983 !screen.track_click(&press_at(col, row), at(120)),
3984 "two clicks on the frame are not a follow"
3985 );
3986 }
3987
3988 #[tokio::test]
3992 async fn a_frame_press_separates_two_buffer_presses() {
3993 let (mut screen, _, _, _dir) = test_screen().await;
3994 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3995 lay_out(&mut screen);
3996 let (frame_col, frame_row) = frame_corner(&mut screen, &tx);
3997 let (col, row) = buffer_origin(&screen);
3998
3999 screen.track_click(&press_at(col, row), at(0));
4000 screen.track_click(&press_at(frame_col, frame_row), at(40));
4001 assert!(!screen.track_click(&press_at(col, row), at(80)));
4002 }
4003
4004 #[tokio::test]
4008 async fn motion_over_the_frame_does_not_break_a_double() {
4009 let (mut screen, _, _, _dir) = test_screen().await;
4010 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4011 lay_out(&mut screen);
4012 let (frame_col, frame_row) = frame_corner(&mut screen, &tx);
4013 let (col, row) = buffer_origin(&screen);
4014
4015 screen.track_click(&press_at(col, row), at(0));
4016 assert!(!screen.track_click(&moved_at(frame_col, frame_row), at(40)));
4017 assert!(screen.track_click(&press_at(col, row), at(120)));
4018 }
4019
4020 #[tokio::test]
4021 async fn a_key_between_presses_ends_the_click_run() {
4022 let (mut screen, _, _, _dir) = test_screen().await;
4023 lay_out(&mut screen);
4024 let (col, row) = buffer_origin(&screen);
4025
4026 screen.track_click(&press_at(col, row), at(0));
4027 assert!(!screen.track_click(&chr('x'), at(40)));
4028 assert!(!screen.track_click(&press_at(col, row), at(80)));
4029 }
4030
4031 #[tokio::test]
4036 async fn an_overlay_ends_the_click_run() {
4037 let (mut screen, _, _, _dir) = test_screen().await;
4038 lay_out(&mut screen);
4039 let (col, row) = buffer_origin(&screen);
4040 let vault = screen.vault.clone();
4041
4042 screen.track_click(&press_at(col, row), at(0));
4043 screen.present_overlay(Box::new(ActiveDialog::create_note(
4044 VaultPath::note_path_from("x"),
4045 vault,
4046 None,
4047 )));
4048 assert!(
4049 !screen.track_click(&press_at(col, row), at(40)),
4050 "a press under an overlay is the overlay's"
4051 );
4052 screen.dismiss_overlay();
4053 assert!(
4054 !screen.track_click(&press_at(col, row), at(80)),
4055 "and neither press survives to pair with the next one"
4056 );
4057 }
4058
4059 #[tokio::test]
4063 async fn a_double_click_on_a_wikilink_follows_it() {
4064 let (mut screen, _, _, _dir) = test_screen().await;
4065 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4066 screen
4067 .panels
4068 .editor_mut()
4069 .unwrap()
4070 .set_text("[[target note]]".to_string());
4071 lay_out(&mut screen);
4072 let (col, row) = buffer_origin(&screen);
4075 let (col, row) = (col + 6, row);
4076
4077 screen.handle_input(&press_at(col, row), &tx);
4078 screen.handle_input(&press_at(col, row), &tx);
4079
4080 let followed = std::iter::from_fn(|| rx.try_recv().ok())
4081 .find_map(|ev| match ev {
4082 AppEvent::FollowLink(target) => Some(target),
4083 _ => None,
4084 })
4085 .expect("the double-click emitted no FollowLink");
4086 assert_eq!(followed, "target note");
4087 }
4088
4089 #[tokio::test]
4092 async fn a_single_press_on_a_wikilink_only_places_the_cursor() {
4093 let (mut screen, _, _, _dir) = test_screen().await;
4094 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4095 screen
4096 .panels
4097 .editor_mut()
4098 .unwrap()
4099 .set_text("[[target note]]".to_string());
4100 lay_out(&mut screen);
4101 let (col, row) = buffer_origin(&screen);
4102
4103 screen.handle_input(&press_at(col + 6, row), &tx);
4104
4105 assert!(
4106 !std::iter::from_fn(|| rx.try_recv().ok())
4107 .any(|ev| matches!(ev, AppEvent::FollowLink(_))),
4108 "one click is a cursor placement, not a navigation"
4109 );
4110 assert!(
4111 screen
4112 .panels
4113 .editor()
4114 .unwrap()
4115 .follow_target_at_cursor()
4116 .is_some(),
4117 "but it did land on the link, so the next press has something to follow"
4118 );
4119 }
4120}
4121
4122#[cfg(test)]
4123mod sort_routing_tests {
4124 use super::*;
4125 use crate::app_screen::AppScreen;
4126 use crate::components::events::SortTarget;
4127 use crate::components::file_list::{SortField, SortOrder};
4128
4129 async fn make_editor() -> (
4130 EditorScreen,
4131 AppTx,
4132 tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
4133 ) {
4134 let vault = crate::test_support::temp_vault("editor-sort").await;
4135 vault.validate_and_init().await.unwrap();
4136 let settings = std::sync::Arc::new(std::sync::RwLock::new(
4137 crate::settings::AppSettings::default(),
4138 ));
4139 let screen = EditorScreen::new(vault, VaultPath::root(), settings);
4140 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
4141 (screen, tx, rx)
4142 }
4143
4144 #[tokio::test(flavor = "multi_thread")]
4145 async fn sort_save_default_persists_to_settings() {
4146 let (mut screen, tx, _rx) = make_editor().await;
4147 screen
4148 .handle_app_message(
4149 AppEvent::SortChanged {
4150 target: SortTarget::Sidebar,
4151 field: SortField::Title,
4152 order: SortOrder::Descending,
4153 group_directories: true,
4154 persist: true,
4155 },
4156 &tx,
4157 )
4158 .await;
4159 let s = screen.settings.read().unwrap();
4160 assert_eq!(
4161 s.default_sort_field,
4162 crate::settings::SortFieldSetting::Title
4163 );
4164 assert_eq!(
4165 s.default_sort_order,
4166 crate::settings::SortOrderSetting::Descending
4167 );
4168 assert!(s.group_directories);
4169 }
4170
4171 #[tokio::test(flavor = "multi_thread")]
4172 async fn sort_save_default_journal_dir_writes_journal_settings() {
4173 let (mut screen, tx, _rx) = make_editor().await;
4174 let journal = screen.vault.journal_path().clone();
4176 screen.panels.sidebar_mut().navigate(journal, &tx);
4177
4178 screen
4179 .handle_app_message(
4180 AppEvent::SortChanged {
4181 target: SortTarget::Sidebar,
4182 field: SortField::Title,
4183 order: SortOrder::Ascending,
4184 group_directories: false,
4185 persist: true,
4186 },
4187 &tx,
4188 )
4189 .await;
4190
4191 let s = screen.settings.read().unwrap();
4192 assert_eq!(
4193 s.journal_sort_field,
4194 crate::settings::SortFieldSetting::Title
4195 );
4196 assert_eq!(
4197 s.journal_sort_order,
4198 crate::settings::SortOrderSetting::Ascending
4199 );
4200 assert_eq!(
4202 s.default_sort_field,
4203 crate::settings::SortFieldSetting::Name
4204 );
4205 }
4206
4207 #[tokio::test(flavor = "multi_thread")]
4210 async fn sort_changed_without_persist_leaves_settings() {
4211 let (mut screen, tx, _rx) = make_editor().await;
4212 screen
4213 .handle_app_message(
4214 AppEvent::SortChanged {
4215 target: SortTarget::Sidebar,
4216 field: SortField::Title,
4217 order: SortOrder::Descending,
4218 group_directories: true,
4219 persist: false,
4220 },
4221 &tx,
4222 )
4223 .await;
4224 let s = screen.settings.read().unwrap();
4225 assert_eq!(
4226 s.default_sort_field,
4227 crate::settings::SortFieldSetting::Name
4228 );
4229 assert_eq!(
4230 s.default_sort_order,
4231 crate::settings::SortOrderSetting::Ascending
4232 );
4233 assert!(!s.group_directories);
4234 }
4235}