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.set_text(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.set_text(text.clone());
659 ed.mark_saved(text);
660 }
661 self.panels
662 .sidebar_mut()
663 .set_open_note(Some(self.path.clone()));
664 self.doc_meta.note_opened(&self.path, tx);
665 self.reflect_open_note_in_drawers(tx);
666 let interval = self.settings.read().unwrap().autosave_interval_secs;
668 self.autosave.restart(interval, tx.clone());
669 }
670 Err(_) => {
671 self.autosave.stop();
676 let parent = to.get_parent_path().0;
677 tx.send(AppEvent::OpenScreen(ScreenEvent::OpenBrowse(
678 self.vault.clone(),
679 parent,
680 )))
681 .ok();
682 }
683 }
684 }
685 }
686}
687
688impl EditorScreen {
689 fn input_ctx(&self, double_click: bool) -> InputCtx {
696 InputCtx {
697 overlay: self.overlays.active_kind(),
698 leader_pending: self.leader.is_pending(),
699 focused: self.panels.focused(),
700 drawer_view: self.panels.active_drawer_view(),
701 space_leads: self.panels.editor().is_some_and(|e| e.space_leads()),
702 claim: self.panels.editor().map(|e| e.claim()).unwrap_or_default(),
703 double_click,
704 }
705 }
706
707 fn track_click(&mut self, event: &InputEvent, now: std::time::Instant) -> bool {
731 let InputEvent::Mouse(mouse) = event else {
732 self.clicks.end();
733 return false;
734 };
735 if self.overlays.is_open() {
736 self.clicks.end();
737 return false;
738 }
739 let on_buffer = matches!(
744 mouse.kind,
745 ratatui::crossterm::event::MouseEventKind::Down(_)
746 ) && self.panels.is_note_buffer_cell(mouse.column, mouse.row);
747 self.clicks.observe(mouse, on_buffer, now)
748 }
749
750 fn apply_classification(
754 &mut self,
755 classification: Classification,
756 event: &InputEvent,
757 tx: &AppTx,
758 ) -> EventState {
759 if let Some(chord) = classification.flash {
760 self.footer.flash(chord, tx);
761 }
762 if classification.cancel_leader {
763 self.leader.cancel();
764 }
765 self.execute_intent(classification.intent, event, tx)
766 }
767
768 fn execute_intent(
769 &mut self,
770 intent: EditorIntent,
771 event: &InputEvent,
772 tx: &AppTx,
773 ) -> EventState {
774 match intent {
775 EditorIntent::Consume => EventState::Consumed,
776 EditorIntent::EditorPaste => {
777 if !self.try_paste_image(tx)
778 && let InputEvent::Paste(text) = event
779 && !text.is_empty()
780 && let Some(ed) = self.panels.editor_mut()
781 {
782 ed.paste_text(text, tx);
783 }
784 EventState::Consumed
785 }
786 EditorIntent::ImageProbe => {
787 if self.try_paste_image(tx) {
788 EventState::Consumed
789 } else {
790 let ctx = self.input_ctx(false);
796 let classification = {
797 let s = self.settings.read().unwrap();
798 crate::app_screen::editor_input::classify_tail(
799 event,
800 &s.key_bindings,
801 &ctx,
802 false,
803 )
804 };
805 self.apply_classification(classification, event, tx)
806 }
807 }
808 EditorIntent::FollowLink => {
809 if self.follow_link_at_cursor(tx) {
810 EventState::Consumed
811 } else if matches!(event, InputEvent::Mouse(_)) {
812 self.execute_intent(EditorIntent::Mouse, event, tx)
820 } else {
821 EventState::Consumed
822 }
823 }
824 EditorIntent::LeaderKey(key) => self.handle_leader_key(&key, tx),
825 EditorIntent::LeaderStart => {
826 self.leader.start();
827 self.schedule_whichkey_reveal(tx);
828 EventState::Consumed
829 }
830 EditorIntent::Op(op) => {
831 self.run_op(op, tx);
832 EventState::Consumed
833 }
834 EditorIntent::ToggleOverlay { kind, open } => {
835 if self.overlays.active_kind() == Some(kind) {
836 self.dismiss_overlay();
837 } else {
838 self.open_overlay(open, tx);
839 }
840 EventState::Consumed
841 }
842 EditorIntent::OpenOverlay(open) => {
843 self.open_overlay(open, tx);
844 EventState::Consumed
845 }
846 EditorIntent::Overlay => self.overlays.handle_input(event, tx),
847 EditorIntent::Mouse => {
848 let state = self.panels.handle_mouse(event, tx);
852 self.ask.sync_sources(&mut self.panels, tx);
854 if self.panels.editor().is_some_and(|e| e.wants_context_menu) {
858 if let Some(ed) = self.panels.editor_mut() {
859 ed.wants_context_menu = false;
860 }
861 tx.send(AppEvent::FileOp(FileOp::ShowMenu(self.path.clone())))
862 .ok();
863 }
864 state
865 }
866 EditorIntent::Panel { fallback } => {
867 let state = self.panels.handle_input(event, tx);
868 self.ask.sync_sources(&mut self.panels, tx);
871 if state == EventState::NotConsumed {
872 match fallback {
873 PanelFallback::None => state,
874 PanelFallback::FocusCycle(CycleDir::Right) => {
875 self.focus_right(tx);
876 EventState::Consumed
877 }
878 PanelFallback::FocusCycle(CycleDir::Left) => {
879 self.focus_left(tx);
880 EventState::Consumed
881 }
882 PanelFallback::FocusEditor => {
883 self.focus_editor();
884 EventState::Consumed
885 }
886 }
887 } else {
888 state
889 }
890 }
891 }
892 }
893
894 fn run_op(&mut self, op: EditorOp, tx: &AppTx) {
895 match op {
896 EditorOp::ToggleDrawer => self.toggle_drawer(tx),
897 EditorOp::FocusLeft => self.focus_left(tx),
898 EditorOp::FocusRight => self.focus_right(tx),
899 EditorOp::OpenJournal => {
900 tx.send(AppEvent::OpenJournal).ok();
901 }
902 EditorOp::ShowFileOps => {
903 tx.send(AppEvent::FileOp(FileOp::ShowMenu(self.path.clone())))
904 .ok();
905 }
906 EditorOp::ToggleQueryPanel => self.toggle_backlinks(tx),
907 EditorOp::OpenFileBrowserReveal => {
908 self.open_drawer_view(DrawerView::Files, tx);
909 self.reveal_note_dir_in_sidebar(tx);
910 }
911 EditorOp::SaveCurrentQuery => {
912 if let Some((query, provenance, source)) = self.save_query_source() {
913 self.present_overlay(Box::new(ActiveDialog::save_search(
917 query,
918 provenance,
919 source,
920 self.vault.clone(),
921 tx,
922 )));
923 }
924 }
925 EditorOp::FindInBuffer => {
926 if let Some(ed) = self.panels.editor_mut() {
927 ed.open_or_advance_search();
928 }
929 }
930 EditorOp::ReplaceInBuffer => {
931 if let Some(ed) = self.panels.editor_mut() {
932 ed.open_replace();
933 }
934 }
935 EditorOp::ApplyText(text_action) => {
936 if let Some(ed) = self.panels.editor_mut() {
937 ed.apply_text_action(text_action);
938 }
939 }
940 EditorOp::OpenAsk => self.open_ask_workspace(tx),
941 }
942 }
943
944 fn open_overlay(&mut self, open: OverlayOpen, tx: &AppTx) {
949 if self.overlays.is_open() {
950 return;
951 }
952 let overlay = self.build_overlay(open, tx);
953 self.present_overlay(overlay);
954 }
955
956 fn open_note(&self) -> Option<&VaultPath> {
964 if self.panels.is_showing_ask() || !self.path.is_note() {
965 None
966 } else {
967 Some(&self.path)
968 }
969 }
970
971 fn open_note_or_flash(&self, tx: &AppTx) -> Option<VaultPath> {
973 let note = self.open_note().cloned();
974 if note.is_none() {
975 tx.send(AppEvent::FlashMessage("no note open".into())).ok();
976 }
977 note
978 }
979
980 fn toggle_pin_open_note(&self, tx: &AppTx) {
985 let Some(path) = self.open_note_or_flash(tx) else {
986 return;
987 };
988 let vault = self.vault.clone();
989 let tx = tx.clone();
990 tokio::spawn(async move {
991 let msg = match vault.toggle_pinned_note(&path).await {
992 Ok(kimun_core::PinToggle::Pinned { position }) => format!(
993 "pinned as {} of {}",
994 position + 1,
995 kimun_core::PINNED_NOTES_CAP
996 ),
997 Ok(kimun_core::PinToggle::Unpinned) => "unpinned".to_string(),
998 Ok(kimun_core::PinToggle::Full) => format!(
999 "already {} pinned — unpin one first",
1000 kimun_core::PINNED_NOTES_CAP
1001 ),
1002 Err(VaultError::FSError(FSError::VaultPathNotFound { .. })) => {
1003 format!("note not found: {path}")
1004 }
1005 Err(e) => {
1006 tracing::warn!("failed to toggle pin on {path}: {e}");
1007 format!("could not update pinned notes: {e}")
1008 }
1009 };
1010 tx.send(AppEvent::FlashMessage(msg)).ok();
1011 });
1012 }
1013
1014 fn jump_to_pinned(&self, n: u8, tx: &AppTx) {
1017 let vault = self.vault.clone();
1018 let tx = tx.clone();
1019 tokio::spawn(async move {
1020 let pins = match vault.list_pinned_notes().await {
1021 Ok(pins) => pins,
1022 Err(e) => {
1023 tx.send(AppEvent::FlashMessage(format!(
1024 "could not read pinned notes: {e}"
1025 )))
1026 .ok();
1027 return;
1028 }
1029 };
1030 let Some(path) = pins.get(usize::from(n).saturating_sub(1)).cloned() else {
1031 tx.send(AppEvent::FlashMessage(format!("no pinned note {n}")))
1032 .ok();
1033 return;
1034 };
1035 if !vault.exists(&path).await {
1036 tx.send(AppEvent::FlashMessage(format!(
1037 "pinned note not found: {path}"
1038 )))
1039 .ok();
1040 return;
1041 }
1042 tx.send(AppEvent::OpenPath {
1043 path,
1044 emphasis: None,
1045 })
1046 .ok();
1047 });
1048 }
1049
1050 fn build_overlay(&self, open: OverlayOpen, tx: &AppTx) -> Box<dyn Overlay> {
1055 let s = self.settings.read().unwrap();
1056 match open {
1057 OverlayOpen::SearchBrowser => {
1060 let provider = resolving_search_source(
1061 self.vault.clone(),
1062 s.current_last_paths(),
1063 Some(self.path.clone()),
1064 );
1065 Box::new(NoteBrowserModal::new(
1066 "Note Browser",
1067 BrowserScope::Query,
1068 provider,
1069 self.vault.clone(),
1070 s.key_bindings.clone(),
1071 s.icons(),
1072 tx.clone(),
1073 ))
1074 }
1075 OverlayOpen::FileFinder => {
1078 let current_dir = self.path.get_parent_path().0;
1079 let provider = FileFinderProvider::new(self.vault.clone(), current_dir);
1080 Box::new(NoteBrowserModal::new(
1081 "Find Note",
1082 BrowserScope::Files,
1083 provider,
1084 self.vault.clone(),
1085 s.key_bindings.clone(),
1086 s.icons(),
1087 tx.clone(),
1088 ))
1089 }
1090 OverlayOpen::SavedSearches => Box::new(SavedSearchesModal::new(
1092 self.vault.clone(),
1093 s.key_bindings.clone(),
1094 s.icons(),
1095 tx.clone(),
1096 )),
1097 OverlayOpen::CommandPalette => {
1099 let gateway = s
1100 .key_bindings
1101 .first_combo_for(&ActionShortcuts::Leader)
1102 .unwrap_or_else(|| "leader".to_string());
1103 Box::new(
1104 crate::components::command_palette::CommandPaletteModal::new(
1105 &s.leader_tree(),
1106 &gateway,
1107 s.icons(),
1108 tx.clone(),
1109 ),
1110 )
1111 }
1112 OverlayOpen::WorkspaceSwitcher => Box::new(ActiveDialog::workspace_switcher(&s)),
1114 OverlayOpen::ThemePicker => Box::new(ActiveDialog::theme_picker(&s)),
1117 OverlayOpen::Help => Box::new(ActiveDialog::help(&s.key_bindings, &s.leader_tree())),
1118 OverlayOpen::QueryHelp => Box::new(ActiveDialog::query_syntax()),
1119 OverlayOpen::Cheatsheet => Box::new(ActiveDialog::cheatsheet(&s)),
1120 OverlayOpen::SortQuery => {
1121 let (field, order) = self.panels.query().current_order();
1122 Box::new(ActiveDialog::sort(SortTarget::Query, field, order, false))
1123 }
1124 OverlayOpen::SortSidebar => {
1125 let (field, order) = self.panels.sidebar().current_sort();
1126 Box::new(ActiveDialog::sort(
1127 SortTarget::Sidebar,
1128 field,
1129 order,
1130 self.panels.sidebar().group_dirs(),
1131 ))
1132 }
1133 OverlayOpen::QuickNote => Box::new(ActiveDialog::quick_note(self.vault.clone())),
1134 OverlayOpen::PinnedNotes => {
1136 Box::new(ActiveDialog::pinned_notes(self.vault.clone(), tx))
1137 }
1138 }
1139 }
1140
1141 fn handle_update(&mut self, flow: UpdateFlow, tx: &AppTx) {
1145 match flow {
1146 UpdateFlow::Available(status) => {
1147 self.update = Some(status);
1148 }
1149 UpdateFlow::ShowDialog => {
1150 if let Some(status) = self.update.clone() {
1151 self.present_overlay(Box::new(ActiveDialog::update(&status)));
1152 }
1153 }
1154 UpdateFlow::Dismiss(_) => {
1155 self.update = None;
1157 }
1158 UpdateFlow::Applied => {
1159 self.update = None;
1162 }
1163 UpdateFlow::Apply => {
1164 let tx2 = tx.clone();
1165 tx.send(AppEvent::FlashMessage("Downloading update…".into()))
1166 .ok();
1167 tokio::spawn(async move {
1168 let result = async {
1169 let latest = crate::update::latest_release().await?;
1170 crate::update::install(latest).await
1171 }
1172 .await;
1173 let msg = match result {
1174 Ok(()) => {
1175 tx2.send(AppEvent::Update(UpdateFlow::Applied)).ok();
1176 "Update installed — restart kimün to apply".to_string()
1177 }
1178 Err(e) => format!("Update failed: {e}"),
1179 };
1180 tx2.send(AppEvent::FlashMessage(msg)).ok();
1181 });
1182 }
1183 }
1184 }
1185
1186 async fn handle_file_op(&mut self, op: FileOp, tx: &AppTx) {
1189 match op {
1190 FileOp::ShowMenu(path) => {
1191 self.present_overlay(Box::new(ActiveDialog::file_ops_menu(path)));
1192 }
1193 FileOp::ShowDelete(path) => {
1194 self.present_overlay(Box::new(ActiveDialog::delete(path, self.vault.clone())));
1195 }
1196 FileOp::ShowRename(path) => {
1197 self.present_overlay(Box::new(ActiveDialog::rename(path, self.vault.clone())));
1198 }
1199 FileOp::ShowMove(path) => {
1200 self.present_overlay(Box::new(ActiveDialog::move_to(
1201 path,
1202 self.vault.clone(),
1203 tx,
1204 )));
1205 }
1206 FileOp::ShowCreateWithContent { path, content } => {
1207 let path = self.vault.free_note_path(&path).await;
1215 self.present_overlay(Box::new(ActiveDialog::create_note(
1216 path,
1217 self.vault.clone(),
1218 Some(content),
1219 )));
1220 }
1221 FileOp::Created(path) => {
1222 self.refresh_sidebar_if_showing(&path.get_parent_path().0, tx);
1226 }
1227 FileOp::Deleted(path) => {
1228 self.on_entry_op(path, tx).await;
1229 }
1230 FileOp::Renamed { from, to } => {
1231 if from.is_note() {
1234 self.on_note_renamed(from, to, tx).await;
1235 } else {
1236 self.on_entry_op(from, tx).await;
1237 }
1238 }
1239 FileOp::Moved { from, .. } => {
1240 self.on_entry_op(from, tx).await;
1241 }
1242 }
1243 }
1244
1245 fn handle_saved_search(&mut self, flow: SavedSearchFlow, tx: &AppTx) {
1247 match flow {
1248 SavedSearchFlow::Selected { query, name } => {
1249 self.overlays.close();
1253 self.apply_saved_search(query, name, tx);
1254 }
1255 SavedSearchFlow::Confirmed {
1256 name,
1257 query,
1258 source,
1259 } => {
1260 let vault = self.vault.clone();
1264 let tx = tx.clone();
1265 tokio::spawn(async move {
1266 match vault.save_search(&name, &query).await {
1267 Ok(()) => {
1268 tx.send(AppEvent::SavedSearch(SavedSearchFlow::Persisted {
1269 name,
1270 query,
1271 source,
1272 }))
1273 .ok();
1274 }
1275 Err(e) => {
1276 tracing::warn!("failed to save search '{}': {}", name, e);
1277 tx.send(AppEvent::SavedSearch(SavedSearchFlow::SaveFailed { name }))
1278 .ok();
1279 }
1280 }
1281 });
1282 }
1283 SavedSearchFlow::Persisted {
1290 name,
1291 query,
1292 source: SaveSource::QueryPanel,
1293 } if name.trim() != query.trim() => {
1294 self.panels.query_mut().repin_saved_search(name, &query);
1295 }
1296 SavedSearchFlow::Persisted { .. } => {}
1297 SavedSearchFlow::SaveFailed { name } => {
1298 self.footer
1299 .flash(format!("Failed to save search '{name}'"), tx);
1300 }
1301 }
1302 }
1303
1304 async fn handle_owned_message(&mut self, msg: AppEvent, tx: &AppTx) {
1307 match msg {
1308 AppEvent::RagStatus(status) => {
1309 let ask_was = self.rag_status.llm_available();
1310 let sem_was = self.rag_status.search_available();
1311 self.rag_status = status;
1312 let ask_now = status.llm_available();
1318 let sem_now = status.search_available();
1319 if ask_was != ask_now || sem_was != sem_now {
1320 let (kb, icons) = {
1321 let s = self.settings.read().unwrap();
1322 (s.key_bindings.clone(), s.icons())
1323 };
1324 self.panels.rebuild_rail(
1325 kb,
1326 icons,
1327 crate::components::activity_rail::RailCaps {
1328 semantic: sem_now,
1329 ask: ask_now,
1330 },
1331 );
1332 }
1333 if ask_was != ask_now {
1334 self.ask
1335 .refresh_capability(&mut self.panels, self.rag_status)
1336 .await;
1337 }
1338 }
1339 AppEvent::Ask(data) => self.ask.handle_data(&mut self.panels, data, tx),
1340 AppEvent::FlashMessage(msg) => {
1343 self.footer.flash(msg, tx);
1344 }
1345 AppEvent::ExecuteLeaderAction(action) => {
1346 if self.overlays.is_open() {
1347 tracing::warn!("ExecuteLeaderAction({action:?}) dropped: overlay open");
1350 } else {
1351 self.execute_leader_action(action, tx);
1352 }
1353 }
1354 AppEvent::ApplyTheme { theme, persist } => {
1355 {
1358 let mut s = self.settings.write().unwrap();
1359 s.set_theme(theme.name.clone());
1360 }
1361 self.theme = (*theme).adapt_to_terminal();
1362 if persist {
1363 let snapshot = self.settings.read().unwrap().clone();
1364 tokio::spawn(async move {
1365 snapshot.save_to_disk().ok();
1366 });
1367 }
1368 tx.send(AppEvent::Redraw).ok();
1369 }
1370 AppEvent::RunTagQuery(label) if !self.overlays.is_open() => {
1373 self.open_find_with_query(format!("#{label}"), None, tx);
1374 }
1375 AppEvent::JumpToHeading(heading) if !self.overlays.is_open() => {
1376 if let Some(ed) = self.panels.editor_mut() {
1377 ed.jump_to_heading(&heading);
1378 }
1379 self.focus_editor();
1380 }
1381 AppEvent::OpenDrawerView(view) => {
1382 if view == DrawerView::Semantic && !self.rag_status.search_available() {
1386 let msg = if crate::rag::rag_configured(&self.settings) {
1387 "Semantic search needs a reachable server with an embedder"
1388 } else {
1389 "Set kimun_server_url in config to use semantic search"
1390 };
1391 tx.send(AppEvent::FlashMessage(msg.into())).ok();
1392 }
1393 else if view == DrawerView::Ask && !self.rag_status.llm_available() {
1396 tx.send(AppEvent::FlashMessage(
1397 "Ask needs an LLM-configured server; this one is semantic-search only"
1398 .into(),
1399 ))
1400 .ok();
1401 }
1402 else if self.panels.is_visible(PanelKind::Drawer)
1405 && self.panels.active_drawer_view() == view
1406 {
1407 self.panels.hide(PanelKind::Drawer);
1408 } else {
1409 self.open_drawer_view(view, tx);
1410 }
1411 }
1412 AppEvent::CloseOverlay => {
1413 self.dismiss_overlay();
1419 }
1420 AppEvent::SortChanged {
1421 target,
1422 field,
1423 order,
1424 group_directories,
1425 persist,
1426 } => {
1427 match target {
1428 SortTarget::Sidebar if persist => {
1429 let is_journal = self.panels.sidebar().is_current_journal();
1434 self.panels
1435 .sidebar_mut()
1436 .save_default(field, order, group_directories);
1437 {
1438 let mut s = self.settings.write().unwrap();
1439 if is_journal {
1440 s.journal_sort_field =
1441 crate::settings::SortFieldSetting::from(field);
1442 s.journal_sort_order =
1443 crate::settings::SortOrderSetting::from(order);
1444 } else {
1445 s.default_sort_field =
1446 crate::settings::SortFieldSetting::from(field);
1447 s.default_sort_order =
1448 crate::settings::SortOrderSetting::from(order);
1449 }
1450 s.group_directories = group_directories;
1451 }
1452 let snapshot = self.settings.read().unwrap().clone();
1453 tokio::spawn(async move {
1454 snapshot.save_to_disk().ok();
1455 });
1456 }
1457 SortTarget::Sidebar => {
1458 self.panels
1459 .sidebar_mut()
1460 .apply_sort(field, order, group_directories)
1461 }
1462 SortTarget::Query => self.panels.query_mut().apply_sort(field, order, tx),
1465 }
1466 }
1467 AppEvent::Autosave => {
1468 self.spawn_autosave(tx);
1469 }
1470 AppEvent::AutosaveCompleted {
1471 path,
1472 saved_revision,
1473 title,
1474 } => {
1475 if path == self.path
1476 && let Some(rev) = saved_revision
1477 && let Some(ed) = self.panels.editor_mut()
1478 {
1479 ed.mark_saved_at_revision(rev);
1480 }
1481 if let Some(raw_title) = title {
1482 self.note_saved(&path, raw_title);
1483 }
1484 self.doc_meta.refresh_git(tx);
1487 }
1496 AppEvent::FocusSidebar => {
1497 self.focus_sidebar(tx);
1498 }
1499 AppEvent::FollowLink(target) => {
1500 self.follow_link(target, tx).await;
1501 }
1502 AppEvent::FollowLabel(name) => {
1503 let initial = format!("#{name}");
1504 let s = self.settings.read().unwrap();
1505 let provider = resolving_search_source(
1506 self.vault.clone(),
1507 s.current_last_paths(),
1508 Some(self.path.clone()),
1509 );
1510 let modal = NoteBrowserModal::with_initial_query(
1511 "Note Browser",
1512 BrowserScope::Query,
1513 provider,
1514 self.vault.clone(),
1515 s.key_bindings.clone(),
1516 s.icons(),
1517 tx.clone(),
1518 initial,
1519 );
1520 drop(s);
1521 self.present_overlay(Box::new(modal));
1522 }
1523 AppEvent::InsertAtCursor(text) if self.panels.focused() == PanelKind::Editor => {
1524 if let Some(ed) = self.panels.editor_mut() {
1525 ed.insert_at_cursor(&text, tx);
1526 }
1527 }
1528 _ => {}
1529 }
1530 }
1531
1532 pub fn focus_editor(&mut self) {
1533 self.panels.focus(PanelKind::Editor);
1534 }
1535
1536 fn drawer_open_on(&self, view: DrawerView) -> bool {
1538 self.panels.is_visible(PanelKind::Drawer) && self.panels.active_drawer_view() == view
1539 }
1540
1541 fn reflect_open_note_in_drawers(&mut self, tx: &AppTx) {
1546 let path = self.path.clone();
1547 if self.drawer_open_on(DrawerView::Find) {
1548 self.panels.query_mut().set_note(path.clone(), tx.clone());
1549 }
1550 if self.panels.is_visible(PanelKind::Drawer) {
1551 match self.panels.active_drawer_view() {
1552 DrawerView::Links => self.panels.links_mut().set_note(path.clone(), tx),
1553 DrawerView::Outline => self.panels.outline_mut().set_note(path, tx),
1554 _ => {}
1555 }
1556 }
1557 }
1558
1559 fn reveal_note_dir_in_sidebar(&mut self, tx: &AppTx) {
1563 let note_parent = self.path.get_parent_path().0;
1564 if self.panels.sidebar().is_empty()
1565 || !note_parent.is_like(self.panels.sidebar().current_dir())
1566 {
1567 self.navigate_sidebar(note_parent, tx);
1568 }
1569 }
1570
1571 pub fn focus_sidebar(&mut self, tx: &AppTx) {
1575 if !self.panels.is_visible(PanelKind::Drawer) {
1576 self.open_drawer_view(DrawerView::Files, tx);
1580 } else {
1581 self.panels.focus(PanelKind::Drawer);
1582 }
1583 }
1584
1585 fn open_drawer_view(&mut self, view: DrawerView, tx: &AppTx) {
1589 self.ask.transition(&mut self.panels, view, tx);
1593 let newly_shown = !self.drawer_open_on(view);
1594 self.panels.open_drawer_view(view);
1595 self.drawer_view_revealed(view, newly_shown, tx);
1596 if view == DrawerView::Ask {
1600 self.panels.focus(PanelKind::Editor);
1601 } else {
1602 self.panels.focus(PanelKind::Drawer);
1603 }
1604 }
1605
1606 fn ensure_ask_workspace(&mut self, tx: &AppTx) {
1610 if !self.panels.is_showing_ask() {
1611 self.open_drawer_view(DrawerView::Ask, tx);
1612 }
1613 }
1614
1615 fn with_ask_panel(&mut self, tx: &AppTx, f: impl FnOnce(&mut ThreadPanel, &AppTx)) {
1619 self.ensure_ask_workspace(tx);
1620 f(self.panels.ask_mut(), tx);
1621 }
1622
1623 fn drawer_view_revealed(&mut self, view: DrawerView, newly_shown: bool, tx: &AppTx) {
1631 match view {
1632 DrawerView::Find if newly_shown => {
1633 self.panels
1634 .query_mut()
1635 .set_note(self.path.clone(), tx.clone());
1636 }
1637 DrawerView::Files if newly_shown => {
1638 self.reveal_note_dir_in_sidebar(tx);
1639 }
1640 DrawerView::Config => {
1641 let info = {
1642 let s = self.settings.read().unwrap();
1643 let key_of = |a: &ActionShortcuts| {
1644 s.key_bindings
1645 .first_combo_for(a)
1646 .unwrap_or_else(|| "unbound".to_string())
1647 };
1648 crate::components::drawer::ConfigInfo {
1649 theme_name: s.get_theme().name,
1650 leader_key: key_of(&ActionShortcuts::Leader),
1651 preferences_key: key_of(&ActionShortcuts::OpenPreferences),
1652 leader_timeout_ms: s.leader_timeout_ms,
1653 config_path: s
1654 .config_file
1655 .as_ref()
1656 .map(|p| p.display().to_string())
1657 .unwrap_or_else(|| "default location".to_string()),
1658 }
1659 };
1660 self.panels.drawer_set_config_info(info);
1661 }
1662 DrawerView::Semantic => self.panels.semantic_mut().ensure_source(tx),
1663 DrawerView::Tags => self.panels.tags_mut().refresh(tx),
1664 DrawerView::Links => self.panels.links_mut().set_note(self.path.clone(), tx),
1665 DrawerView::Outline => self.panels.outline_mut().set_note(self.path.clone(), tx),
1666 _ => {}
1667 }
1668 }
1669
1670 fn apply_text_from_leader(&mut self, action: TextAction, tx: &AppTx) {
1680 if self.input_ctx(false).accepts_text_action() {
1681 self.run_op(EditorOp::ApplyText(action), tx);
1682 } else {
1683 self.footer
1684 .flash("formatting needs the note focused".to_string(), tx);
1685 }
1686 }
1687
1688 fn focus_left(&mut self, _tx: &AppTx) {
1690 if let Some(kind) = self.panels.prev_kind() {
1691 self.panels.focus(kind);
1692 }
1693 }
1694
1695 fn focus_right(&mut self, _tx: &AppTx) {
1697 if let Some(kind) = self.panels.next_kind() {
1698 self.panels.focus(kind);
1699 }
1700 }
1701
1702 fn toggle_drawer(&mut self, tx: &AppTx) {
1709 if self.panels.is_visible(PanelKind::Drawer) {
1710 self.panels.hide(PanelKind::Drawer);
1711 } else {
1712 self.panels.show(PanelKind::Drawer);
1713 self.drawer_view_revealed(self.panels.active_drawer_view(), true, tx);
1714 }
1715 }
1716
1717 fn save_query_source(&self) -> Option<(String, Option<String>, SaveSource)> {
1723 let (query, provenance, source) = match self.overlays.active_kind() {
1724 Some(OverlayKind::NoteBrowser) => (
1725 self.overlays.active_query().unwrap_or_default().to_string(),
1726 self.overlays
1727 .active_saved_search_provenance()
1728 .map(str::to_string),
1729 SaveSource::NoteBrowser,
1730 ),
1731 None => (
1732 self.panels.query().active_query().to_string(),
1733 self.panels.query().saved_search_name().map(str::to_string),
1734 SaveSource::QueryPanel,
1735 ),
1736 Some(_) => return None,
1737 };
1738 if query.trim().is_empty() {
1739 None
1740 } else {
1741 Some((query, provenance, source))
1742 }
1743 }
1744
1745 fn schedule_whichkey_reveal(&self, tx: &AppTx) {
1750 let timeout = {
1751 let s = self.settings.read().unwrap();
1752 std::time::Duration::from_millis(s.leader_timeout_ms)
1753 };
1754 let tx2 = tx.clone();
1755 tokio::spawn(async move {
1756 tokio::time::sleep(timeout + std::time::Duration::from_millis(10)).await;
1757 let _ = tx2.send(AppEvent::Redraw);
1758 });
1759 }
1760
1761 fn open_ask_workspace(&mut self, tx: &AppTx) {
1770 if self.overlays.is_open() {
1771 return;
1772 }
1773 if !self.panels.is_showing_ask() {
1774 if !self.rag_status.llm_available() {
1775 tx.send(AppEvent::FlashMessage(
1776 "Ask needs an LLM-configured server; this one is semantic-search only".into(),
1777 ))
1778 .ok();
1779 return;
1780 }
1781 self.open_drawer_view(DrawerView::Ask, tx);
1782 }
1783 self.panels.ask_mut().focus_composer();
1784 self.panels.focus(PanelKind::Editor);
1785 }
1786
1787 fn follow_link_at_cursor(&mut self, tx: &AppTx) -> bool {
1795 use crate::components::text_editor::FollowTarget;
1796 if self.panels.is_showing_attachment() {
1799 self.open_attachment_externally(tx);
1800 return true;
1801 }
1802 let Some(editor) = self.panels.editor_mut() else {
1803 return false;
1804 };
1805 match editor.follow_target_at_cursor() {
1806 Some(FollowTarget::Link(target)) => {
1807 tx.send(AppEvent::FollowLink(target)).ok();
1808 true
1809 }
1810 Some(FollowTarget::Label(name)) => {
1811 tx.send(AppEvent::FollowLabel(name)).ok();
1812 true
1813 }
1814 None => false,
1815 }
1816 }
1817
1818 fn open_attachment_externally(&mut self, tx: &AppTx) {
1821 let Some(path) = self.panels.attachment_path() else {
1822 return;
1823 };
1824 let os_path = self.vault.path_to_pathbuf(path);
1825 match open::that_detached(&os_path) {
1826 Ok(()) => self
1827 .footer
1828 .flash(format!("Opening {}", os_path.display()), tx),
1829 Err(e) => self.footer.flash(format!("Cannot open: {e}"), tx),
1830 }
1831 }
1832
1833 fn handle_leader_key(
1838 &mut self,
1839 key: &ratatui::crossterm::event::KeyEvent,
1840 tx: &AppTx,
1841 ) -> EventState {
1842 use ratatui::crossterm::event::{KeyCode, KeyModifiers};
1843 match key.code {
1844 KeyCode::Esc => {
1845 self.leader.cancel();
1846 self.focus_editor();
1847 }
1848 KeyCode::Backspace => {
1849 let outcome = self.leader.step_up();
1850 if outcome == LeaderOutcome::SteppedUp {
1852 self.schedule_whichkey_reveal(tx);
1853 }
1854 }
1855 KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
1856 match self.leader.feed(c) {
1857 LeaderOutcome::Fired(action) => self.execute_leader_action(action, tx),
1858 LeaderOutcome::Invalid => {
1859 self.footer.flash(format!("leader: no entry for '{c}'"), tx);
1861 self.schedule_whichkey_reveal(tx);
1862 }
1863 LeaderOutcome::Descended => self.schedule_whichkey_reveal(tx),
1864 _ => {}
1865 }
1866 }
1867 _ => {}
1871 }
1872 tx.send(AppEvent::Redraw).ok();
1873 EventState::Consumed
1874 }
1875
1876 fn execute_leader_action(&mut self, action: LeaderAction, tx: &AppTx) {
1879 match action {
1880 LeaderAction::OpenDrawer(view) => self.open_drawer_view(view, tx),
1881
1882 LeaderAction::AppCheckUpdates => {
1883 if let Some(status) = self.update.clone() {
1884 self.present_overlay(Box::new(ActiveDialog::update(&status)));
1885 } else {
1886 let tx2 = tx.clone();
1888 tx.send(AppEvent::FlashMessage("Checking for updates…".into()))
1889 .ok();
1890 tokio::spawn(async move {
1891 let Ok(config_dir) = crate::settings::config_dir() else {
1892 return;
1893 };
1894 match crate::update::check_now(config_dir, true).await {
1895 Ok(Some(status)) if status.update_available => {
1896 tx2.send(AppEvent::Update(UpdateFlow::Available(status)))
1900 .ok();
1901 tx2.send(AppEvent::Update(UpdateFlow::ShowDialog)).ok();
1902 }
1903 Ok(_) => {
1904 tx2.send(AppEvent::FlashMessage("kimün is up to date".into()))
1905 .ok();
1906 }
1907 Err(e) => {
1908 tx2.send(AppEvent::FlashMessage(format!(
1909 "Update check failed: {e}"
1910 )))
1911 .ok();
1912 }
1913 }
1914 });
1915 }
1916 }
1917
1918 LeaderAction::FindFiles => self.open_overlay(OverlayOpen::FileFinder, tx),
1921 LeaderAction::FindGrep => self.open_overlay(OverlayOpen::SearchBrowser, tx),
1922 LeaderAction::FindTags => self.open_drawer_view(DrawerView::Tags, tx),
1923 LeaderAction::FindBacklinks => {
1924 self.open_find_with_query("<{note}".to_string(), None, tx)
1925 }
1926 LeaderAction::FindSaved => self.open_overlay(OverlayOpen::SavedSearches, tx),
1927 LeaderAction::FindRecent => self.open_overlay(OverlayOpen::SearchBrowser, tx),
1928 LeaderAction::FindHeadings => self.open_drawer_view(DrawerView::Outline, tx),
1929
1930 LeaderAction::NoteNew => {
1932 self.open_drawer_view(DrawerView::Files, tx);
1936 self.footer
1937 .flash("type a name — Enter creates".to_string(), tx);
1938 }
1939 LeaderAction::NoteDaily => {
1940 tx.send(AppEvent::OpenJournal).ok();
1941 }
1942 LeaderAction::NoteFromTemplate => {
1943 self.footer.flash("templates — coming soon".to_string(), tx);
1944 }
1945 LeaderAction::NoteRename => {
1946 if let Some(path) = self.open_note_or_flash(tx) {
1947 tx.send(AppEvent::FileOp(FileOp::ShowRename(path))).ok();
1948 }
1949 }
1950 LeaderAction::NoteMove => {
1951 if let Some(path) = self.open_note_or_flash(tx) {
1952 tx.send(AppEvent::FileOp(FileOp::ShowMove(path))).ok();
1953 }
1954 }
1955 LeaderAction::NoteDelete => {
1956 if let Some(path) = self.open_note_or_flash(tx) {
1957 tx.send(AppEvent::FileOp(FileOp::ShowDelete(path))).ok();
1958 }
1959 }
1960
1961 LeaderAction::LinksTab(tab) => {
1963 self.open_drawer_view(DrawerView::Links, tx);
1964 self.panels.links_mut().show_tab(tab, tx);
1965 }
1966 LeaderAction::LinksGraph => {
1967 self.footer
1968 .flash("local graph — coming soon".to_string(), tx);
1969 }
1970
1971 LeaderAction::GitStatus => {
1974 self.doc_meta.refresh_git(tx);
1975 let msg = self
1976 .doc_meta
1977 .git()
1978 .cloned()
1979 .unwrap_or_else(|| "not a git repository".to_string());
1980 self.footer.flash(msg, tx);
1981 }
1982 LeaderAction::GitSync | LeaderAction::GitLog | LeaderAction::GitDiff => {
1983 self.footer
1984 .flash("git is display-only for now".to_string(), tx);
1985 }
1986
1987 LeaderAction::VaultSwitch => self.open_overlay(OverlayOpen::WorkspaceSwitcher, tx),
1989 LeaderAction::VaultReindex => {
1990 let vault = self.vault.clone();
1993 let tx2 = tx.clone();
1994 self.footer.flash("reindexing…".to_string(), tx);
1995 tokio::spawn(async move {
1996 let started = std::time::Instant::now();
1997 let result = vault.index_notes(kimun_core::NotesValidation::Fast).await;
1998 let msg = match result {
1999 Ok(_) => format!("reindexed in {:.1?}", started.elapsed()),
2000 Err(e) => format!("reindex failed: {e}"),
2001 };
2002 tx2.send(AppEvent::FlashMessage(msg)).ok();
2003 });
2004 }
2005 LeaderAction::VaultConfig => self.open_drawer_view(DrawerView::Config, tx),
2008 LeaderAction::VaultTheme => self.open_overlay(OverlayOpen::ThemePicker, tx),
2009 LeaderAction::VaultPreferences => {
2010 tx.send(AppEvent::OpenScreen(ScreenEvent::OpenPreferences))
2011 .ok();
2012 }
2013 LeaderAction::AppOnboarding => {
2014 tx.send(AppEvent::OpenScreen(ScreenEvent::OpenOnboarding))
2015 .ok();
2016 }
2017
2018 LeaderAction::WindowZen => {
2020 self.panels.hide(PanelKind::Drawer);
2021 self.focus_editor();
2022 }
2023 LeaderAction::WindowSplit => {
2024 self.footer
2025 .flash("editor splits — coming soon".to_string(), tx);
2026 }
2027 LeaderAction::WindowGrowDrawer => self.panels.adjust_drawer_width(4),
2028 LeaderAction::WindowShrinkDrawer => self.panels.adjust_drawer_width(-4),
2029
2030 LeaderAction::NoteToggleTodo => {
2032 self.footer
2033 .flash("toggle todo — coming soon".to_string(), tx);
2034 }
2035 LeaderAction::NotePreview => {
2036 self.footer
2037 .flash("preview — lands with phase 09".to_string(), tx);
2038 }
2039 LeaderAction::NoteCopyWikilink => {
2040 if let Some(path) = self.open_note_or_flash(tx) {
2041 let link = format!("[[{}]]", path.get_clean_name());
2042 crate::components::yank(link, "wikilink copied", tx);
2043 }
2044 }
2045 LeaderAction::NoteExport => {
2046 self.footer.flash("export — coming soon".to_string(), tx);
2047 }
2048 LeaderAction::NoteYankPath => {
2049 if let Some(path) = self.open_note_or_flash(tx) {
2050 crate::components::yank(path.to_string(), "note path copied", tx);
2051 }
2052 }
2053
2054 LeaderAction::TextBold => self.apply_text_from_leader(TextAction::Bold, tx),
2060 LeaderAction::TextItalic => self.apply_text_from_leader(TextAction::Italic, tx),
2061 LeaderAction::TextStrikethrough => {
2062 self.apply_text_from_leader(TextAction::Strikethrough, tx)
2063 }
2064
2065 LeaderAction::AskFocus => self.open_ask_workspace(tx),
2067 LeaderAction::AskNew => {
2068 self.with_ask_panel(tx, |panel, _tx| {
2069 panel.thread_mut().clear();
2070 panel.focus_composer();
2071 });
2072 self.panels.ask_sources_mut().reset(tx);
2073 }
2074 LeaderAction::AskCopy => {
2075 self.with_ask_panel(tx, |panel, tx| panel.copy_selected(tx));
2076 }
2077 LeaderAction::AskSave => {
2078 self.with_ask_panel(tx, |panel, tx| panel.save_selected(tx));
2079 }
2080 LeaderAction::AskRegenerate => {
2081 self.with_ask_panel(tx, |panel, tx| panel.regenerate_selected(tx));
2082 self.ask.sync_sources(&mut self.panels, tx);
2083 }
2084 LeaderAction::AskSource => {
2085 self.ensure_ask_workspace(tx);
2086 self.ask.sync_sources_from_selected(&mut self.panels, tx);
2089 self.panels.ask_sources_mut().open_reader(0, tx);
2090 self.panels.focus(PanelKind::Drawer);
2091 }
2092
2093 LeaderAction::Palette => self.open_overlay(OverlayOpen::CommandPalette, tx),
2094 LeaderAction::Help => self.open_overlay(OverlayOpen::Cheatsheet, tx),
2095
2096 LeaderAction::NoteSave => {
2097 self.spawn_autosave(tx);
2100 }
2101 LeaderAction::AppQuit => {
2102 tx.send(AppEvent::Quit).ok();
2103 }
2104
2105 LeaderAction::FindPinned => self.open_overlay(OverlayOpen::PinnedNotes, tx),
2107 LeaderAction::NoteTogglePin => self.toggle_pin_open_note(tx),
2108 LeaderAction::PinnedJump(n) => self.jump_to_pinned(n, tx),
2109 }
2110 }
2111
2112 fn open_find_with_query(&mut self, query: String, name: Option<String>, tx: &AppTx) {
2122 self.panels.open_drawer_view(DrawerView::Find);
2123 self.panels.query_mut().apply_query(query, name, tx.clone());
2124 self.panels.focus(PanelKind::Drawer);
2125 }
2126
2127 fn apply_saved_search(&mut self, query: String, name: String, tx: &AppTx) {
2128 self.open_find_with_query(query, Some(name), tx);
2133 }
2134
2135 fn toggle_backlinks(&mut self, tx: &AppTx) {
2136 if self.drawer_open_on(DrawerView::Find) {
2137 self.panels.hide(PanelKind::Drawer);
2138 } else {
2139 self.open_drawer_view(DrawerView::Find, tx);
2140 }
2141 }
2142}
2143
2144#[async_trait]
2145impl AppScreen for EditorScreen {
2146 fn get_kind(&self) -> ScreenKind {
2147 ScreenKind::Editor
2148 }
2149
2150 async fn on_enter(&mut self, tx: &AppTx) {
2151 self.app_tx = Some(tx.clone());
2152 self.open_path(self.path.clone(), None, tx).await;
2153 }
2154
2155 fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
2156 let double_click = self.track_click(event, std::time::Instant::now());
2164 let ctx = self.input_ctx(double_click);
2165 let classification = if matches!(event, InputEvent::Key(_)) {
2169 let s = self.settings.read().unwrap();
2170 classify(event, &s.key_bindings, &ctx)
2171 } else {
2172 classify(event, &KeyBindings::empty(), &ctx)
2173 };
2174 self.apply_classification(classification, event, tx)
2175 }
2176
2177 fn render(&mut self, f: &mut ratatui::Frame) {
2178 let theme = &self.theme;
2179 f.render_widget(
2180 ratatui::widgets::Block::default().style(theme.base_style()),
2181 f.area(),
2182 );
2183
2184 let rows = Layout::default()
2185 .direction(Direction::Vertical)
2186 .constraints([
2187 Constraint::Length(1),
2188 Constraint::Min(0),
2189 Constraint::Length(crate::components::footer_bar::STATUS_BAR_HEIGHT),
2190 ])
2191 .split(f.area());
2192
2193 let workspace_badge = {
2197 let s = self.settings.read().unwrap();
2198 s.workspace_config.as_ref().map(|wc| {
2199 ratatui::text::Line::from(vec![
2200 ratatui::text::Span::styled(
2201 self.icons.workspace,
2202 Style::default().fg(theme.accent.to_ratatui()),
2203 ),
2204 ratatui::text::Span::styled(
2205 format!(" {}", wc.global.current_workspace),
2206 Style::default().fg(theme.gray.to_ratatui()),
2207 ),
2208 ])
2209 })
2210 };
2211 let workspace_label_width = workspace_badge
2212 .as_ref()
2213 .map(ratatui::text::Line::width)
2214 .unwrap_or_default();
2215 let breadcrumb = self
2216 .path
2217 .to_string()
2218 .trim_start_matches('/')
2219 .replace('/', " / ");
2220 let title_cols = Layout::default()
2221 .direction(Direction::Horizontal)
2222 .constraints([
2223 Constraint::Min(0),
2224 Constraint::Length(workspace_label_width as u16 + 2),
2225 ])
2226 .split(rows[0]);
2227 f.render_widget(
2228 Paragraph::new(ratatui::text::Line::from(vec![
2229 ratatui::text::Span::styled(
2230 " Kimün ",
2231 Style::default()
2232 .fg(theme.accent.to_ratatui())
2233 .add_modifier(ratatui::style::Modifier::BOLD),
2234 ),
2235 ratatui::text::Span::styled(
2236 format!("─ {breadcrumb}"),
2237 Style::default().fg(theme.fg_secondary.to_ratatui()),
2238 ),
2239 ])),
2240 title_cols[0],
2241 );
2242 if let Some(badge) = workspace_badge {
2243 f.render_widget(
2244 Paragraph::new(badge).alignment(ratatui::layout::Alignment::Right),
2245 title_cols[1],
2246 );
2247 }
2248
2249 self.panels
2252 .render(f, rows[1], theme, !self.overlays.is_open());
2253
2254 let (focus_label, hints) = if let Some(kind) = self.overlays.active_kind() {
2258 (kind.label(), self.overlays.hint_shortcuts())
2259 } else {
2260 (self.panels.focused_label(), self.panels.focused_hints())
2261 };
2262 let editing = if let Some(kind) = self.overlays.active_kind() {
2263 !matches!(kind, OverlayKind::Dialog)
2266 } else {
2267 match self.panels.focused() {
2268 PanelKind::Editor => true,
2269 PanelKind::Drawer => self.panels.drawer_is_text_input(),
2270 PanelKind::Rail => false,
2271 }
2272 };
2273 let path_str = self.path.to_string();
2274 let link_segment = if self.panels.focused() == PanelKind::Editor && !self.overlays.is_open()
2277 {
2278 let link = self
2279 .panels
2280 .editor()
2281 .and_then(|e| e.follow_target_at_cursor());
2282 self.doc_meta
2283 .link_segment(link.as_ref(), &self.path, self.app_tx.as_ref())
2284 } else {
2285 None
2286 };
2287 let ln_col = (self.panels.focused() == PanelKind::Editor && !self.overlays.is_open())
2290 .then(|| {
2291 self.panels.editor().map(|e| {
2292 let (row, col) = e.cursor_pos();
2293 (row + 1, col + 1)
2294 })
2295 })
2296 .flatten();
2297 let matches = (self.panels.focused() == PanelKind::Drawer
2299 && self.panels.active_drawer_view() == DrawerView::Find)
2300 .then(|| self.panels.query().result_count());
2301 let (global_hints, leader_timeout, gateway_label) = {
2302 let s = self.settings.read().unwrap();
2303 (
2304 crate::components::hints::global_hints(&s.key_bindings),
2305 std::time::Duration::from_millis(s.leader_timeout_ms),
2306 s.key_bindings
2307 .first_combo_for(&ActionShortcuts::Leader)
2308 .unwrap_or_else(|| "leader".to_string()),
2309 )
2310 };
2311 let ctx = crate::components::footer_bar::StatusContext {
2312 focus_label,
2313 editing,
2314 hints: &hints,
2315 global_hints: &global_hints,
2316 doc: crate::components::footer_bar::DocState {
2317 path: &path_str,
2318 dirty: self.panels.editor().is_some_and(|e| e.is_dirty()),
2319 ln_col,
2320 backlinks: self.doc_meta.backlinks(),
2321 git: self.doc_meta.git().cloned(),
2322 matches,
2323 link: link_segment,
2324 update: self
2325 .update
2326 .as_ref()
2327 .map(|u| format!("⬆ {} available", u.latest)),
2328 rag: self.rag_status.label().map(|s| s.to_string()),
2329 },
2330 };
2331 self.footer.render(f, rows[2], theme, &ctx);
2332
2333 let whichkey_visible = self
2336 .leader
2337 .pending_since()
2338 .is_some_and(|since| since.elapsed() >= leader_timeout);
2339 if whichkey_visible {
2340 let gateway = gateway_label;
2341 let area = f.area();
2342 let h = crate::components::which_key::desired_height(&self.leader, area.width)
2343 .min(rows[1].height);
2344 let rect =
2345 ratatui::layout::Rect::new(area.x, rows[2].y.saturating_sub(h), area.width, h);
2346 crate::components::which_key::render(f, rect, theme, &self.leader, &gateway);
2347 }
2348
2349 self.overlays.render(f, f.area(), &self.theme);
2351 }
2352
2353 async fn handle_app_message(&mut self, msg: AppEvent, tx: &AppTx) {
2354 match msg {
2355 AppEvent::OverlayData(data) => {
2360 self.overlays.handle_data(&data, &self.vault, tx);
2361 }
2362 AppEvent::Update(flow) => self.handle_update(flow, tx),
2363 AppEvent::FileOp(op) => self.handle_file_op(op, tx).await,
2364 AppEvent::SavedSearch(flow) => self.handle_saved_search(flow, tx),
2365 msg => {
2366 let Some(msg) = self.doc_meta.handle(msg, &self.path) else {
2369 return;
2370 };
2371 self.handle_owned_message(msg, tx).await;
2372 }
2373 }
2374 }
2375
2376 async fn try_open_path(
2379 &mut self,
2380 path: VaultPath,
2381 emphasis: Option<Vec<String>>,
2382 tx: &AppTx,
2383 ) -> Option<VaultPath> {
2384 self.dismiss_overlay();
2385 if path.is_note() {
2386 self.open_path(path, emphasis, tx).await;
2387 self.focus_editor();
2388 } else {
2389 self.navigate_sidebar(path, tx);
2390 }
2391 None
2392 }
2393
2394 async fn try_open_attachment(&mut self, path: VaultPath, tx: &AppTx) -> Option<VaultPath> {
2395 self.dismiss_overlay();
2396 self.open_attachment(path, tx).await;
2397 None
2398 }
2399
2400 async fn on_exit(&mut self, _tx: &AppTx) {
2401 self.try_save().await;
2402 }
2403}
2404
2405#[cfg(test)]
2406mod tests {
2407 use super::*;
2408 use crate::keys::key_event_to_combo;
2409
2410 #[test]
2412 fn panel_kind_labels_and_overlay_kind_compile() {
2413 assert_eq!(PanelKind::Editor.label(), "EDITOR");
2414 assert_eq!(PanelKind::Rail.label(), "RAIL");
2415 assert_eq!(DrawerView::Files.label(), "FILES");
2416 assert_eq!(DrawerView::Find.label(), "FIND");
2417 let _kind = OverlayKind::Dialog;
2418 }
2419
2420 async fn test_screen() -> (
2423 EditorScreen,
2424 Arc<NoteVault>,
2425 SharedSettings,
2426 tempfile::TempDir,
2427 ) {
2428 use crate::settings::AppSettings;
2429 use kimun_core::VaultConfig;
2430 use std::sync::RwLock;
2431
2432 let dir = tempfile::TempDir::new().unwrap();
2433 let vault = Arc::new(
2434 NoteVault::new(VaultConfig::new(crate::test_support::sys(dir.path())))
2435 .await
2436 .unwrap(),
2437 );
2438 let settings: SharedSettings = Arc::new(RwLock::new(AppSettings::default()));
2439 let screen = EditorScreen::new(vault.clone(), VaultPath::root(), settings.clone());
2440 (screen, vault, settings, dir)
2441 }
2442
2443 fn key_event(code: ratatui::crossterm::event::KeyCode) -> InputEvent {
2444 use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
2445 InputEvent::Key(KeyEvent::new(code, KeyModifiers::NONE))
2446 }
2447
2448 fn ctrl_key(c: char) -> InputEvent {
2449 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2450 InputEvent::Key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL))
2451 }
2452
2453 fn chr(c: char) -> InputEvent {
2454 key_event(ratatui::crossterm::event::KeyCode::Char(c))
2455 }
2456
2457 #[tokio::test]
2460 async fn leader_sequence_opens_drawer_view() {
2461 let (mut screen, _, _, _dir) = test_screen().await;
2462 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2463
2464 screen.panels.open_drawer_view(DrawerView::Tags);
2466
2467 screen.handle_input(&ctrl_key('g'), &tx);
2468 assert!(screen.leader.is_pending());
2469 screen.handle_input(&chr('o'), &tx);
2470 screen.handle_input(&chr('f'), &tx);
2471
2472 assert!(!screen.leader.is_pending());
2473 assert_eq!(screen.panels.active_drawer_view(), DrawerView::Files);
2474 assert_eq!(screen.panels.focused(), PanelKind::Drawer);
2475 }
2476
2477 #[tokio::test]
2482 async fn leader_formatting_yields_to_the_find_bar() {
2483 let (mut screen, _, _, _dir) = test_screen().await;
2484 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2485 screen
2486 .panels
2487 .editor_mut()
2488 .unwrap()
2489 .set_text("plain".to_string());
2490 screen.run_op(EditorOp::FindInBuffer, &tx);
2491 assert_eq!(
2492 screen.panels.editor().unwrap().claim(),
2493 crate::components::text_editor::EditorClaim::FindBar
2494 );
2495
2496 screen.handle_input(&ctrl_key('g'), &tx);
2497 screen.handle_input(&chr('t'), &tx);
2498 screen.handle_input(&chr('b'), &tx);
2499
2500 assert!(!screen.leader.is_pending());
2501 let text = screen.panels.editor().unwrap().get_text();
2502 assert!(
2503 !text.contains("**"),
2504 "the note was formatted under a claim: {text:?}"
2505 );
2506 }
2507
2508 #[tokio::test]
2511 async fn leader_formatting_applies_with_the_editor_focused() {
2512 let (mut screen, _, _, _dir) = test_screen().await;
2513 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2514 screen
2515 .panels
2516 .editor_mut()
2517 .unwrap()
2518 .set_text("plain".to_string());
2519
2520 screen.handle_input(&ctrl_key('g'), &tx);
2521 screen.handle_input(&chr('t'), &tx);
2522 screen.handle_input(&chr('b'), &tx);
2523
2524 let text = screen.panels.editor().unwrap().get_text();
2525 assert!(text.contains("**"), "no bold marker was inserted: {text:?}");
2526 }
2527
2528 #[tokio::test]
2531 async fn opening_files_drawer_reveals_current_note_dir() {
2532 let (mut screen, _, _, _dir) = test_screen().await;
2533 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2534
2535 screen.path = VaultPath::new("projects").append(&VaultPath::note_path_from("plan"));
2536 screen
2538 .panels
2539 .sidebar_mut()
2540 .navigate(VaultPath::new("other"), &tx);
2541 screen.panels.hide(PanelKind::Drawer);
2542
2543 screen.open_drawer_view(DrawerView::Files, &tx);
2544
2545 assert!(
2546 screen
2547 .panels
2548 .sidebar()
2549 .current_dir()
2550 .is_like(&VaultPath::new("projects"))
2551 );
2552 }
2553
2554 #[tokio::test]
2558 async fn file_browser_shortcut_rereveals_note_dir_when_already_open() {
2559 let (mut screen, _, _, _dir) = test_screen().await;
2560 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2561
2562 screen.path = VaultPath::new("projects").append(&VaultPath::note_path_from("plan"));
2563 screen.open_drawer_view(DrawerView::Files, &tx);
2564 screen
2566 .panels
2567 .sidebar_mut()
2568 .navigate(VaultPath::new("other"), &tx);
2569
2570 screen.handle_input(&ctrl_key('e'), &tx);
2571
2572 assert!(
2573 screen
2574 .panels
2575 .sidebar()
2576 .current_dir()
2577 .is_like(&VaultPath::new("projects"))
2578 );
2579 }
2580
2581 #[tokio::test]
2584 async fn leader_consumes_keys_while_editor_focused() {
2585 let (mut screen, _, _, _dir) = test_screen().await;
2586 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2587
2588 screen.panels.editor_mut().unwrap().set_text(String::new());
2589 assert_eq!(screen.panels.focused(), PanelKind::Editor);
2590
2591 screen.handle_input(&ctrl_key('g'), &tx);
2592 assert!(screen.leader.is_pending());
2593 screen.handle_input(&chr('w'), &tx);
2595 screen.handle_input(&chr('z'), &tx); assert_eq!(screen.panels.editor().unwrap().get_text(), "");
2597 assert!(!screen.panels.is_visible(PanelKind::Drawer));
2598 }
2599
2600 #[tokio::test]
2602 async fn leader_esc_cancels_and_focuses_editor() {
2603 let (mut screen, _, _, _dir) = test_screen().await;
2604 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2605
2606 screen.panels.focus(PanelKind::Rail);
2607 screen.handle_input(&ctrl_key('g'), &tx);
2608 screen.handle_input(&chr('f'), &tx);
2609 screen.handle_input(&key_event(ratatui::crossterm::event::KeyCode::Esc), &tx);
2610
2611 assert!(!screen.leader.is_pending());
2612 assert_eq!(screen.panels.focused(), PanelKind::Editor);
2613 }
2614
2615 async fn wait_for(
2617 rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
2618 pred: impl Fn(&AppEvent) -> bool,
2619 ) -> AppEvent {
2620 tokio::time::timeout(std::time::Duration::from_secs(2), async {
2621 loop {
2622 let e = rx.recv().await.expect("channel open");
2623 if pred(&e) {
2624 break e;
2625 }
2626 }
2627 })
2628 .await
2629 .expect("expected event")
2630 }
2631
2632 #[tokio::test(flavor = "multi_thread")]
2635 async fn leader_f_p_opens_the_pinned_notes_dialog() {
2636 let (mut screen, _, _, _dir) = test_screen().await;
2637 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2638 screen.handle_input(&ctrl_key('g'), &tx);
2639 screen.handle_input(&chr('f'), &tx);
2640 screen.handle_input(&chr('p'), &tx);
2641 assert!(screen.overlays.is_open());
2642 assert_eq!(
2643 screen.overlays.active_kind(),
2644 Some(crate::components::overlay::OverlayKind::Dialog)
2645 );
2646 }
2647
2648 #[tokio::test(flavor = "multi_thread")]
2651 async fn leader_m_i_toggles_the_pin_and_flashes() {
2652 let (mut screen, vault, _, _dir) = test_screen().await;
2653 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2654 let note = VaultPath::new("plan.md");
2655 vault.create_note(¬e, "hi").await.unwrap();
2656 screen.path = note.clone();
2657
2658 screen.handle_input(&ctrl_key('g'), &tx);
2659 screen.handle_input(&chr('m'), &tx);
2660 screen.handle_input(&chr('i'), &tx);
2661 let e = wait_for(&mut rx, |e| matches!(e, AppEvent::FlashMessage(_))).await;
2662 assert!(
2663 matches!(&e, AppEvent::FlashMessage(m) if m == "pinned as 1 of 9"),
2664 "{e:?}"
2665 );
2666 assert_eq!(
2668 vault.list_pinned_notes().await.unwrap(),
2669 vec![VaultPath::new("/plan.md")]
2670 );
2671
2672 screen.handle_input(&ctrl_key('g'), &tx);
2673 screen.handle_input(&chr('m'), &tx);
2674 screen.handle_input(&chr('i'), &tx);
2675 let e = wait_for(&mut rx, |e| matches!(e, AppEvent::FlashMessage(_))).await;
2676 assert!(
2677 matches!(&e, AppEvent::FlashMessage(m) if m == "unpinned"),
2678 "{e:?}"
2679 );
2680 assert!(vault.list_pinned_notes().await.unwrap().is_empty());
2681 }
2682
2683 #[tokio::test(flavor = "multi_thread")]
2686 async fn leader_m_i_refuses_a_tenth_pin() {
2687 let (mut screen, vault, _, _dir) = test_screen().await;
2688 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2689 for i in 0..kimun_core::PINNED_NOTES_CAP {
2690 let n = VaultPath::new(format!("n{i}.md"));
2691 vault.create_note(&n, "hi").await.unwrap();
2692 vault.toggle_pinned_note(&n).await.unwrap();
2693 }
2694 let extra = VaultPath::new("extra.md");
2695 vault.create_note(&extra, "hi").await.unwrap();
2696 screen.path = extra;
2697 screen.handle_input(&ctrl_key('g'), &tx);
2698 screen.handle_input(&chr('m'), &tx);
2699 screen.handle_input(&chr('i'), &tx);
2700 let e = wait_for(&mut rx, |e| matches!(e, AppEvent::FlashMessage(_))).await;
2701 assert!(
2702 matches!(&e, AppEvent::FlashMessage(m) if m == "already 9 pinned — unpin one first"),
2703 "{e:?}"
2704 );
2705 }
2706
2707 #[tokio::test(flavor = "multi_thread")]
2711 async fn leader_m_i_on_a_missing_note_flashes() {
2712 let (mut screen, vault, _, _dir) = test_screen().await;
2713 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2714 screen.path = VaultPath::new("gone.md");
2715 screen.handle_input(&ctrl_key('g'), &tx);
2716 screen.handle_input(&chr('m'), &tx);
2717 screen.handle_input(&chr('i'), &tx);
2718 let e = wait_for(&mut rx, |e| matches!(e, AppEvent::FlashMessage(_))).await;
2719 assert!(
2720 matches!(&e, AppEvent::FlashMessage(m) if m == "note not found: gone.md"),
2721 "{e:?}"
2722 );
2723 assert!(vault.list_pinned_notes().await.unwrap().is_empty());
2724 }
2725
2726 #[tokio::test(flavor = "multi_thread")]
2730 async fn leader_m_c_with_ask_shown_flashes_like_m_i() {
2731 let (mut screen, vault, _, _dir) = test_screen().await;
2732 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2733 let note = VaultPath::new("plan.md");
2734 vault.create_note(¬e, "hi").await.unwrap();
2735 screen.path = note;
2736 screen.open_drawer_view(DrawerView::Ask, &tx);
2737
2738 screen.handle_input(&ctrl_key('g'), &tx);
2739 screen.handle_input(&chr('m'), &tx);
2740 screen.handle_input(&chr('c'), &tx);
2741 let e = wait_for(&mut rx, |e| matches!(e, AppEvent::FlashMessage(_))).await;
2742 assert!(
2743 matches!(&e, AppEvent::FlashMessage(m) if m == "no note open"),
2744 "{e:?}"
2745 );
2746 }
2747
2748 #[tokio::test(flavor = "multi_thread")]
2751 async fn leader_m_i_with_no_note_open_flashes() {
2752 let (mut screen, _, _, _dir) = test_screen().await;
2753 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2754 screen.handle_input(&ctrl_key('g'), &tx);
2756 screen.handle_input(&chr('m'), &tx);
2757 screen.handle_input(&chr('i'), &tx);
2758 let e = wait_for(&mut rx, |e| matches!(e, AppEvent::FlashMessage(_))).await;
2759 assert!(
2760 matches!(&e, AppEvent::FlashMessage(m) if m == "no note open"),
2761 "{e:?}"
2762 );
2763 }
2764
2765 #[tokio::test(flavor = "multi_thread")]
2768 async fn leader_m_i_with_attachment_shown_flashes() {
2769 let (mut screen, vault, _, _dir) = test_screen().await;
2770 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2771 vault
2772 .save_attachment(&VaultPath::new("assets/diagram.png"), &[1, 2, 3])
2773 .await
2774 .unwrap();
2775 screen
2776 .try_open_attachment(VaultPath::new("assets/diagram.png"), &tx)
2777 .await;
2778 assert!(screen.panels.is_showing_attachment());
2779
2780 screen.handle_input(&ctrl_key('g'), &tx);
2781 screen.handle_input(&chr('m'), &tx);
2782 screen.handle_input(&chr('i'), &tx);
2783 let e = wait_for(&mut rx, |e| matches!(e, AppEvent::FlashMessage(_))).await;
2784 assert!(
2785 matches!(&e, AppEvent::FlashMessage(m) if m == "no note open"),
2786 "{e:?}"
2787 );
2788 }
2789
2790 #[tokio::test(flavor = "multi_thread")]
2794 async fn leader_m_i_with_ask_shown_flashes() {
2795 let (mut screen, vault, _, _dir) = test_screen().await;
2796 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2797 let note = VaultPath::new("plan.md");
2798 vault.create_note(¬e, "hi").await.unwrap();
2799 screen.path = note;
2800 screen.open_drawer_view(DrawerView::Ask, &tx);
2801 assert!(screen.panels.is_showing_ask());
2802
2803 screen.handle_input(&ctrl_key('g'), &tx);
2804 screen.handle_input(&chr('m'), &tx);
2805 screen.handle_input(&chr('i'), &tx);
2806 let e = wait_for(&mut rx, |e| matches!(e, AppEvent::FlashMessage(_))).await;
2807 assert!(
2808 matches!(&e, AppEvent::FlashMessage(m) if m == "no note open"),
2809 "{e:?}"
2810 );
2811 }
2812
2813 #[tokio::test(flavor = "multi_thread")]
2815 async fn leader_digit_opens_the_nth_pinned_note() {
2816 let (mut screen, vault, _, _dir) = test_screen().await;
2817 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2818 let a = VaultPath::new("a.md");
2819 let b = VaultPath::new("b.md");
2820 vault.create_note(&a, "a").await.unwrap();
2821 vault.create_note(&b, "b").await.unwrap();
2822 vault.toggle_pinned_note(&a).await.unwrap();
2823 vault.toggle_pinned_note(&b).await.unwrap();
2824
2825 screen.handle_input(&ctrl_key('g'), &tx);
2826 screen.handle_input(&chr('2'), &tx);
2827 let e = wait_for(&mut rx, |e| matches!(e, AppEvent::OpenPath { .. })).await;
2828 assert!(
2831 matches!(&e, AppEvent::OpenPath { path, .. } if path.is_like(&b)),
2832 "{e:?}"
2833 );
2834 }
2835
2836 #[tokio::test(flavor = "multi_thread")]
2838 async fn leader_digit_with_no_such_pin_flashes() {
2839 let (mut screen, _, _, _dir) = test_screen().await;
2840 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2841 screen.handle_input(&ctrl_key('g'), &tx);
2842 screen.handle_input(&chr('4'), &tx);
2843 let e = wait_for(&mut rx, |e| matches!(e, AppEvent::FlashMessage(_))).await;
2844 assert!(
2845 matches!(&e, AppEvent::FlashMessage(m) if m == "no pinned note 4"),
2846 "{e:?}"
2847 );
2848 }
2849
2850 #[tokio::test(flavor = "multi_thread")]
2853 async fn leader_digit_to_a_missing_pin_flashes() {
2854 let (mut screen, vault, _, dir) = test_screen().await;
2855 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2856 let gone = VaultPath::new("gone.md");
2857 vault.create_note(&gone, "hi").await.unwrap();
2858 vault.toggle_pinned_note(&gone).await.unwrap();
2859 std::fs::remove_file(dir.path().join("gone.md")).unwrap();
2861 screen.handle_input(&ctrl_key('g'), &tx);
2862 screen.handle_input(&chr('1'), &tx);
2863 let e = wait_for(&mut rx, |e| matches!(e, AppEvent::FlashMessage(_))).await;
2864 assert!(
2866 matches!(&e, AppEvent::FlashMessage(m) if m == "pinned note not found: /gone.md"),
2867 "{e:?}"
2868 );
2869 }
2870
2871 #[tokio::test]
2875 async fn space_never_leads() {
2876 let (mut screen, _, _, _dir) = test_screen().await;
2877 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2878
2879 screen.panels.editor_mut().unwrap().set_text(String::new());
2881 screen.handle_input(&chr(' '), &tx);
2882 assert!(!screen.leader.is_pending());
2883 assert_eq!(screen.panels.editor().unwrap().get_text(), " ");
2884
2885 screen.panels.focus(PanelKind::Rail);
2887 screen.handle_input(&chr(' '), &tx);
2888 assert!(!screen.leader.is_pending());
2889
2890 screen.panels.open_drawer_view(DrawerView::Find);
2892 screen.panels.focus(PanelKind::Drawer);
2893 screen.handle_input(&chr(' '), &tx);
2894 assert!(!screen.leader.is_pending());
2895 }
2896
2897 #[tokio::test]
2898 async fn persist_saved_search_writes_via_core() {
2899 let (screen, _, _, _dir) = test_screen().await;
2900
2901 screen.persist_saved_search("t", "#todo").await.unwrap();
2902
2903 let all = screen.vault.list_saved_searches().await.unwrap();
2904 assert!(all.iter().any(|s| s.name == "t" && s.query == "#todo"));
2905 }
2906
2907 #[tokio::test]
2908 async fn applying_saved_search_sets_panel_query_and_focuses_it() {
2909 let (mut screen, _, _, _dir) = test_screen().await;
2910
2911 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2912 screen.apply_saved_search(
2913 "<{note}".to_string(),
2914 "Backlinks (current note)".to_string(),
2915 &tx,
2916 );
2917 assert!(screen.panels.is_visible(PanelKind::Drawer));
2918 assert_eq!(screen.panels.active_drawer_view(), DrawerView::Find);
2919 assert_eq!(screen.panels.query().active_query(), "<{note}");
2920 assert_eq!(screen.panels.focused(), PanelKind::Drawer);
2921 }
2922
2923 #[tokio::test]
2924 async fn saved_search_persisted_repins_panel_breadcrumb() {
2925 let (mut screen, _, _, _dir) = test_screen().await;
2926
2927 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2928 screen.apply_saved_search("#todo".to_string(), "todo".to_string(), &tx);
2929 screen
2930 .panels
2931 .query_mut()
2932 .set_active_query("#todo and #urgent".to_string());
2933
2934 screen
2935 .handle_app_message(
2936 AppEvent::SavedSearch(SavedSearchFlow::Persisted {
2937 name: "urgent-todos".to_string(),
2938 query: "#todo and #urgent".to_string(),
2939 source: SaveSource::QueryPanel,
2940 }),
2941 &tx,
2942 )
2943 .await;
2944
2945 assert_eq!(
2946 screen.panels.query().saved_search_breadcrumb().as_deref(),
2947 Some("urgent-todos"),
2948 "a persisted panel-sourced save re-pins the breadcrumb"
2949 );
2950 }
2951
2952 #[tokio::test]
2953 async fn persisted_note_browser_save_does_not_repin_even_on_equal_query() {
2954 let (mut screen, _, _, _dir) = test_screen().await;
2955
2956 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2957 screen.apply_saved_search("#todo".to_string(), "todo".to_string(), &tx);
2958
2959 screen
2962 .handle_app_message(
2963 AppEvent::SavedSearch(SavedSearchFlow::Persisted {
2964 name: "inbox".to_string(),
2965 query: "#todo".to_string(),
2966 source: SaveSource::NoteBrowser,
2967 }),
2968 &tx,
2969 )
2970 .await;
2971
2972 assert_eq!(
2973 screen.panels.query().saved_search_breadcrumb().as_deref(),
2974 Some("todo"),
2975 "a note-browser save must not steal the panel's provenance"
2976 );
2977 }
2978
2979 #[tokio::test]
2980 async fn persisted_query_as_name_save_skips_repin() {
2981 let (mut screen, _, _, _dir) = test_screen().await;
2982
2983 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2984 screen.apply_saved_search("#todo".to_string(), "todo".to_string(), &tx);
2985
2986 screen
2990 .handle_app_message(
2991 AppEvent::SavedSearch(SavedSearchFlow::Persisted {
2992 name: "#todo".to_string(),
2993 query: "#todo".to_string(),
2994 source: SaveSource::QueryPanel,
2995 }),
2996 &tx,
2997 )
2998 .await;
2999
3000 assert_eq!(
3001 screen.panels.query().saved_search_breadcrumb().as_deref(),
3002 Some("todo"),
3003 "a query-as-name save leaves the breadcrumb alone"
3004 );
3005 }
3006
3007 #[tokio::test]
3008 async fn save_search_confirmed_persists_then_emits_persisted() {
3009 let (mut screen, vault, _, _dir) = test_screen().await;
3010
3011 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3012 screen
3013 .handle_app_message(
3014 AppEvent::SavedSearch(SavedSearchFlow::Confirmed {
3015 name: "mine".to_string(),
3016 query: "#todo".to_string(),
3017 source: SaveSource::QueryPanel,
3018 }),
3019 &tx,
3020 )
3021 .await;
3022
3023 let event = tokio::time::timeout(std::time::Duration::from_secs(5), async {
3025 loop {
3026 match rx.recv().await {
3027 Some(e @ AppEvent::SavedSearch(SavedSearchFlow::Persisted { .. })) => break e,
3028 Some(_) => continue,
3029 None => panic!("channel closed before SavedSearchPersisted"),
3030 }
3031 }
3032 })
3033 .await
3034 .expect("SavedSearchPersisted within timeout");
3035
3036 let AppEvent::SavedSearch(SavedSearchFlow::Persisted {
3037 name,
3038 query,
3039 source,
3040 }) = event
3041 else {
3042 unreachable!()
3043 };
3044 assert_eq!((name.as_str(), query.as_str()), ("mine", "#todo"));
3045 assert_eq!(source, SaveSource::QueryPanel);
3046 let all = vault.list_saved_searches().await.unwrap();
3047 assert!(all.iter().any(|s| s.name == "mine" && s.query == "#todo"));
3048 }
3049
3050 #[tokio::test]
3051 async fn save_query_source_carries_panel_provenance() {
3052 let (mut screen, _, _, _dir) = test_screen().await;
3053
3054 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3055 screen.apply_saved_search("#todo".to_string(), "todo".to_string(), &tx);
3056
3057 assert_eq!(
3058 screen.save_query_source(),
3059 Some((
3060 "#todo".to_string(),
3061 Some("todo".to_string()),
3062 SaveSource::QueryPanel
3063 )),
3064 "the save dialog opens pre-filled with the breadcrumb provenance"
3065 );
3066 }
3067
3068 #[tokio::test]
3075 async fn saved_search_selected_then_close_overlay_keeps_backlinks_focus() {
3076 use crate::settings::AppSettings;
3077 use kimun_core::VaultConfig;
3078 use std::sync::RwLock;
3079
3080 let dir = tempfile::TempDir::new().unwrap();
3081 let vault = Arc::new(
3082 NoteVault::new(VaultConfig::new(crate::test_support::sys(dir.path())))
3083 .await
3084 .unwrap(),
3085 );
3086 let settings: SharedSettings = Arc::new(RwLock::new(AppSettings::default()));
3087 let mut screen = EditorScreen::new(vault, VaultPath::root(), settings);
3088
3089 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3090 screen
3092 .handle_app_message(
3093 AppEvent::SavedSearch(SavedSearchFlow::Selected {
3094 query: "<{note}".to_string(),
3095 name: "Backlinks (current note)".to_string(),
3096 }),
3097 &tx,
3098 )
3099 .await;
3100 screen.handle_app_message(AppEvent::CloseOverlay, &tx).await;
3101
3102 assert!(
3103 screen.panels.focused() == PanelKind::Drawer
3104 && screen.panels.active_drawer_view() == DrawerView::Find,
3105 "focus should remain on the FIND drawer after select + close"
3106 );
3107 assert!(!screen.overlays.is_open(), "overlay should be closed");
3108 }
3109
3110 #[tokio::test]
3115 async fn opener_action_does_not_replace_open_overlay() {
3116 use crate::settings::AppSettings;
3117 use kimun_core::VaultConfig;
3118 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
3119 use std::sync::RwLock;
3120
3121 let dir = tempfile::TempDir::new().unwrap();
3122 let vault = Arc::new(
3123 NoteVault::new(VaultConfig::new(crate::test_support::sys(dir.path())))
3124 .await
3125 .unwrap(),
3126 );
3127 let settings: SharedSettings = Arc::new(RwLock::new(AppSettings::default()));
3128 let mut screen = EditorScreen::new(vault.clone(), VaultPath::root(), settings.clone());
3129
3130 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3131
3132 {
3135 let s = settings.read().unwrap();
3136 let modal = SavedSearchesModal::new(
3137 vault.clone(),
3138 s.key_bindings.clone(),
3139 s.icons(),
3140 tx.clone(),
3141 );
3142 drop(s);
3143 screen.overlays.open(Box::new(modal), screen.opener_focus());
3144 }
3145 assert_eq!(
3146 screen.overlays.active_kind(),
3147 Some(OverlayKind::SavedSearches),
3148 "precondition: SavedSearches overlay is active"
3149 );
3150
3151 let quick_note_event = KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL);
3155 {
3156 let s = settings.read().unwrap();
3157 let combo = key_event_to_combo(&quick_note_event).expect("Ctrl+W maps to a combo");
3158 assert_eq!(
3159 s.key_bindings.get_action(&combo),
3160 Some(ActionShortcuts::QuickNote),
3161 "test assumes Ctrl+W is bound to QuickNote"
3162 );
3163 }
3164
3165 screen.handle_input(&InputEvent::Key(quick_note_event), &tx);
3166
3167 assert_eq!(
3170 screen.overlays.active_kind(),
3171 Some(OverlayKind::SavedSearches),
3172 "open overlay must not be replaced by a QuickNote opener action"
3173 );
3174 }
3175
3176 #[tokio::test]
3180 async fn focus_action_is_noop_while_overlay_open() {
3181 use crate::settings::AppSettings;
3182 use kimun_core::VaultConfig;
3183 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
3184 use std::sync::RwLock;
3185
3186 let dir = tempfile::TempDir::new().unwrap();
3187 let vault = Arc::new(
3188 NoteVault::new(VaultConfig::new(crate::test_support::sys(dir.path())))
3189 .await
3190 .unwrap(),
3191 );
3192 let settings: SharedSettings = Arc::new(RwLock::new(AppSettings::default()));
3193 let mut screen = EditorScreen::new(vault.clone(), VaultPath::root(), settings.clone());
3194
3195 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3196
3197 {
3199 let s = settings.read().unwrap();
3200 let modal = SavedSearchesModal::new(
3201 vault.clone(),
3202 s.key_bindings.clone(),
3203 s.icons(),
3204 tx.clone(),
3205 );
3206 drop(s);
3207 screen.overlays.open(Box::new(modal), screen.opener_focus());
3208 }
3209 assert_ne!(screen.panels.active_drawer_view(), DrawerView::Find);
3210 let focused_before = screen.panels.focused();
3211
3212 let focus_right = KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL);
3214 {
3215 let s = settings.read().unwrap();
3216 let combo = key_event_to_combo(&focus_right).expect("Ctrl+L maps to a combo");
3217 assert_eq!(
3218 s.key_bindings.get_action(&combo),
3219 Some(ActionShortcuts::FocusEditor),
3220 "test assumes Ctrl+L is bound to FocusEditor"
3221 );
3222 }
3223 screen.handle_input(&InputEvent::Key(focus_right), &tx);
3224
3225 assert_eq!(
3226 screen.panels.focused(),
3227 focused_before,
3228 "focus action must not move focus while an overlay is open"
3229 );
3230 assert_ne!(
3231 screen.panels.active_drawer_view(),
3232 DrawerView::Find,
3233 "focus action must not switch the drawer view while an overlay is open"
3234 );
3235 assert_eq!(
3236 screen.overlays.active_kind(),
3237 Some(OverlayKind::SavedSearches),
3238 "overlay stays active"
3239 );
3240 }
3241
3242 #[tokio::test(flavor = "multi_thread")]
3247 async fn open_journal_dismisses_open_overlay() {
3248 let vault = crate::test_support::temp_vault("editor-journal").await;
3249 vault.validate_and_init().await.unwrap();
3250 let settings = std::sync::Arc::new(std::sync::RwLock::new(
3251 crate::settings::AppSettings::default(),
3252 ));
3253 let mut screen = EditorScreen::new(vault.clone(), VaultPath::root(), settings.clone());
3254 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3255
3256 {
3257 let s = settings.read().unwrap();
3258 let modal = SavedSearchesModal::new(
3259 vault.clone(),
3260 s.key_bindings.clone(),
3261 s.icons(),
3262 tx.clone(),
3263 );
3264 drop(s);
3265 screen.present_overlay(Box::new(modal));
3266 }
3267 assert!(screen.overlays.is_open(), "precondition: overlay open");
3268
3269 let (details, _, _) = vault.journal_entry().await.unwrap();
3270 screen.try_open_path(details.path, None, &tx).await;
3271
3272 assert!(
3273 !screen.overlays.is_open(),
3274 "opening the journal must dismiss the overlay before loading the note"
3275 );
3276 }
3277
3278 #[tokio::test(flavor = "multi_thread")]
3282 async fn opening_attachment_shows_attachment_view() {
3283 let vault = crate::test_support::temp_vault("editor-attachment").await;
3284 vault.validate_and_init().await.unwrap();
3285 vault
3286 .save_attachment(&VaultPath::new("assets/diagram.png"), &[1, 2, 3])
3287 .await
3288 .unwrap();
3289 let settings = std::sync::Arc::new(std::sync::RwLock::new(
3290 crate::settings::AppSettings::default(),
3291 ));
3292 let mut screen = EditorScreen::new(vault.clone(), VaultPath::root(), settings);
3293 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3294
3295 assert!(screen.panels.editor().is_some());
3297
3298 screen
3299 .try_open_attachment(VaultPath::new("assets/diagram.png"), &tx)
3300 .await;
3301
3302 assert!(
3303 screen.panels.is_showing_attachment(),
3304 "the editor area shows the attachment view"
3305 );
3306 assert!(
3307 screen.panels.editor().is_none(),
3308 "no note editor is mounted while an attachment is shown"
3309 );
3310 assert_eq!(
3311 screen.panels.attachment_path(),
3312 Some(&VaultPath::new("assets/diagram.png"))
3313 );
3314
3315 vault
3317 .create_note(&VaultPath::new("note.md"), "hi")
3318 .await
3319 .unwrap();
3320 screen.open_path(VaultPath::new("note.md"), None, &tx).await;
3321 assert!(!screen.panels.is_showing_attachment());
3322 assert!(screen.panels.editor().is_some());
3323 }
3324
3325 #[tokio::test]
3326 async fn save_query_from_note_browser_opens_save_dialog() {
3327 use crate::settings::AppSettings;
3328 use kimun_core::VaultConfig;
3329 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
3330 use std::sync::RwLock;
3331
3332 let dir = tempfile::TempDir::new().unwrap();
3333 let vault = Arc::new(
3334 NoteVault::new(VaultConfig::new(crate::test_support::sys(dir.path())))
3335 .await
3336 .unwrap(),
3337 );
3338 let settings: SharedSettings = Arc::new(RwLock::new(AppSettings::default()));
3339 let mut screen = EditorScreen::new(vault.clone(), VaultPath::root(), settings.clone());
3340
3341 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3342
3343 {
3345 let s = settings.read().unwrap();
3346 let provider = resolving_search_source(vault.clone(), s.current_last_paths(), None);
3347 let modal = NoteBrowserModal::with_initial_query(
3348 "Note Browser",
3349 BrowserScope::Query,
3350 provider,
3351 vault.clone(),
3352 s.key_bindings.clone(),
3353 s.icons(),
3354 tx.clone(),
3355 "#todo",
3356 );
3357 drop(s);
3358 screen.overlays.open(Box::new(modal), screen.opener_focus());
3359 }
3360 assert_eq!(
3361 screen.overlays.active_kind(),
3362 Some(OverlayKind::NoteBrowser),
3363 "precondition: note browser is active with a query"
3364 );
3365 assert_eq!(screen.overlays.active_query(), Some("#todo"));
3366
3367 let save_event = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL);
3370 {
3371 let s = settings.read().unwrap();
3372 let combo = key_event_to_combo(&save_event).expect("Ctrl+D maps to a combo");
3373 assert_eq!(
3374 s.key_bindings.get_action(&combo),
3375 Some(ActionShortcuts::SaveCurrentQuery),
3376 "test assumes Ctrl+D is bound to SaveCurrentQuery"
3377 );
3378 }
3379
3380 screen.handle_input(&InputEvent::Key(save_event), &tx);
3381
3382 assert_eq!(
3383 screen.overlays.active_kind(),
3384 Some(OverlayKind::Dialog),
3385 "saving from the note browser opens the save-search dialog"
3386 );
3387 }
3388
3389 #[tokio::test]
3392 async fn opening_a_missing_note_leaves_the_history_alone() {
3393 use crate::settings::AppSettings;
3394 use std::sync::RwLock;
3395
3396 let vault = crate::test_support::temp_vault("editor-missing").await;
3397 vault
3398 .create_note(&VaultPath::new("here.md"), "text")
3399 .await
3400 .unwrap();
3401 let history_dir = tempfile::TempDir::new().unwrap();
3402 let settings: SharedSettings = Arc::new(RwLock::new(AppSettings::for_test_workspace(
3403 "ws",
3404 vault.workspace_path(),
3405 crate::test_support::sys(history_dir.path()),
3406 )));
3407 let mut screen = EditorScreen::new(vault, VaultPath::root(), settings.clone());
3408 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3409
3410 screen.open_path(VaultPath::new("here.md"), None, &tx).await;
3411 screen
3412 .open_path(VaultPath::new("missing.md"), None, &tx)
3413 .await;
3414
3415 assert_eq!(
3416 settings.read().unwrap().current_last_paths(),
3417 vec![VaultPath::new("here.md")]
3418 );
3419 assert_eq!(
3420 screen.overlays.active_kind(),
3421 Some(OverlayKind::Dialog),
3422 "the create-note offer still stands for an explicit open"
3423 );
3424 }
3425
3426 #[tokio::test(flavor = "multi_thread")]
3429 async fn renaming_open_note_retargets_in_place() {
3430 let vault = crate::test_support::temp_vault("editor-rename").await;
3431 vault.validate_and_init().await.unwrap();
3432 let from = VaultPath::note_path_from("old");
3433 vault.create_note(&from, "# Old\n\nbody").await.unwrap();
3434 let settings = std::sync::Arc::new(std::sync::RwLock::new(
3435 crate::settings::AppSettings::default(),
3436 ));
3437 let mut screen = EditorScreen::new(vault.clone(), from.clone(), settings);
3438 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3439 screen.on_enter(&tx).await;
3440
3441 let to = VaultPath::note_path_from("new");
3442 vault.rename_note(&from, &to).await.unwrap();
3443 screen
3444 .handle_app_message(
3445 AppEvent::FileOp(FileOp::Renamed {
3446 from: from.clone(),
3447 to: to.clone(),
3448 }),
3449 &tx,
3450 )
3451 .await;
3452
3453 assert_eq!(screen.path, to, "editor retargets to the new path");
3454 assert!(
3455 !screen.panels.editor().unwrap().is_dirty(),
3456 "reloaded buffer is clean (won't clobber the renamed file)"
3457 );
3458 }
3459
3460 #[tokio::test(flavor = "multi_thread")]
3463 async fn open_then_save_marks_and_retitles_sidebar_row() {
3464 let vault = crate::test_support::temp_vault("editor-marksave").await;
3465 vault.validate_and_init().await.unwrap();
3466 vault
3467 .create_note(&VaultPath::note_path_from("alpha"), "# Alpha\n\nbody")
3468 .await
3469 .unwrap();
3470 let settings = std::sync::Arc::new(std::sync::RwLock::new(
3471 crate::settings::AppSettings::default(),
3472 ));
3473 let path = VaultPath::note_path_from("alpha");
3474 let mut screen = EditorScreen::new(vault.clone(), path.clone(), settings);
3475 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3476 screen.on_enter(&tx).await;
3477 for _ in 0..50 {
3478 screen.panels.sidebar_mut().poll_for_test();
3479 if !screen.panels.sidebar().is_loading_for_test() {
3480 break;
3481 }
3482 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
3483 }
3484
3485 assert!(
3486 screen
3487 .panels
3488 .sidebar()
3489 .note_row_is_open_for_test("alpha.md"),
3490 "the open note's row is marked"
3491 );
3492
3493 screen
3494 .handle_app_message(
3495 AppEvent::AutosaveCompleted {
3496 path: path.clone(),
3497 saved_revision: None,
3498 title: Some("New First Line".to_string()),
3499 },
3500 &tx,
3501 )
3502 .await;
3503
3504 assert_eq!(
3505 screen.panels.sidebar().note_row_title_for_test("alpha.md"),
3506 Some("New First Line".to_string()),
3507 "the saved note's row title updated in place"
3508 );
3509 }
3510
3511 #[tokio::test]
3515 async fn ask_drawer_switch_preserves_thread() {
3516 let (mut screen, _, _, _dir) = test_screen().await;
3517 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3518
3519 screen.open_drawer_view(DrawerView::Ask, &tx);
3521 assert!(screen.panels.is_showing_ask());
3522 {
3523 let panel = screen.panels.ask_mut();
3524 panel.thread_mut().ask("first?".into());
3525 panel.thread_mut().ask("second?".into());
3526 }
3527 assert_eq!(screen.panels.ask().thread().turns().len(), 2);
3528
3529 screen.open_drawer_view(DrawerView::Files, &tx);
3532 assert!(!screen.panels.is_showing_ask());
3533 assert_eq!(screen.panels.ask().thread().turns().len(), 2);
3534
3535 screen.open_drawer_view(DrawerView::Ask, &tx);
3537 assert!(screen.panels.is_showing_ask());
3538 assert_eq!(
3539 screen.panels.ask().thread().turns().len(),
3540 2,
3541 "the conversation survives the round trip"
3542 );
3543 }
3544
3545 #[tokio::test]
3551 async fn ask_answer_ready_reaches_the_thread_via_the_owned_message_path() {
3552 let (mut screen, _, _, _dir) = test_screen().await;
3553 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3554
3555 screen.open_drawer_view(DrawerView::Ask, &tx);
3556 let id = screen.panels.ask_mut().thread_mut().ask("q?".into());
3557
3558 screen
3559 .handle_app_message(
3560 AppEvent::Ask(crate::components::events::AskData::AnswerReady {
3561 turn_id: id,
3562 result: Ok(("the answer".into(), vec![])),
3563 }),
3564 &tx,
3565 )
3566 .await;
3567
3568 let panel = screen.panels.ask_mut();
3569 let turn = panel.thread().selected().expect("a turn is selected");
3570 assert!(matches!(turn.status, crate::ask::TurnStatus::Done));
3571 assert_eq!(turn.answer, "the answer");
3572 }
3573
3574 #[tokio::test]
3579 async fn opening_a_note_over_ask_shows_the_note_and_keeps_the_thread() {
3580 let vault = crate::test_support::temp_vault("editor-ask-open").await;
3581 vault.validate_and_init().await.unwrap();
3582 vault
3583 .create_note(&VaultPath::note_path_from("alpha"), "# Alpha\n\nbody")
3584 .await
3585 .unwrap();
3586 let settings = std::sync::Arc::new(std::sync::RwLock::new(
3587 crate::settings::AppSettings::default(),
3588 ));
3589 let mut screen = EditorScreen::new(vault, VaultPath::root(), settings);
3590 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3591
3592 screen.open_drawer_view(DrawerView::Ask, &tx);
3593 screen
3594 .panels
3595 .ask_mut()
3596 .thread_mut()
3597 .ask("a question?".into());
3598 assert!(screen.panels.is_showing_ask());
3599
3600 screen
3602 .open_path(VaultPath::note_path_from("alpha"), None, &tx)
3603 .await;
3604 assert!(!screen.panels.is_showing_ask(), "the note replaced Ask");
3605 assert!(screen.panels.editor().is_some(), "the note editor is shown");
3606 assert_eq!(
3607 screen.panels.active_drawer_view(),
3608 DrawerView::Files,
3609 "the Sources drawer must not linger on a hidden conversation"
3610 );
3611
3612 screen.open_drawer_view(DrawerView::Ask, &tx);
3614 assert_eq!(
3615 screen.panels.ask().thread().turns().len(),
3616 1,
3617 "the thread survived opening the note"
3618 );
3619 }
3620
3621 #[tokio::test]
3627 async fn opening_a_note_over_ask_with_hidden_drawer_keeps_it_hidden() {
3628 let vault = crate::test_support::temp_vault("editor-ask-hidden-drawer").await;
3629 vault.validate_and_init().await.unwrap();
3630 vault
3631 .create_note(&VaultPath::note_path_from("alpha"), "# Alpha\n\nbody")
3632 .await
3633 .unwrap();
3634 let settings = std::sync::Arc::new(std::sync::RwLock::new(
3635 crate::settings::AppSettings::default(),
3636 ));
3637 let mut screen = EditorScreen::new(vault, VaultPath::root(), settings);
3638 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3639
3640 screen.open_drawer_view(DrawerView::Ask, &tx);
3642 screen.panels.hide(PanelKind::Drawer);
3643 assert!(!screen.panels.is_visible(PanelKind::Drawer));
3644 assert!(screen.panels.is_showing_ask());
3645
3646 screen
3649 .open_path(VaultPath::note_path_from("alpha"), None, &tx)
3650 .await;
3651 assert!(!screen.panels.is_showing_ask(), "the note replaced Ask");
3652 assert!(
3653 !screen.panels.is_visible(PanelKind::Drawer),
3654 "a hidden drawer must not be force-revealed"
3655 );
3656 assert_eq!(
3657 screen.panels.active_drawer_view(),
3658 DrawerView::Files,
3659 "the view switched off Ask to FILES"
3660 );
3661 }
3662
3663 #[tokio::test]
3671 async fn rail_click_reenters_ask_in_one_click_after_a_note_stashed_it() {
3672 let vault = crate::test_support::temp_vault("editor-ask-rail-reentry").await;
3673 vault.validate_and_init().await.unwrap();
3674 vault
3675 .create_note(&VaultPath::note_path_from("alpha"), "# Alpha\n\nbody")
3676 .await
3677 .unwrap();
3678 let settings = std::sync::Arc::new(std::sync::RwLock::new(
3679 crate::settings::AppSettings::default(),
3680 ));
3681 let mut screen = EditorScreen::new(vault, VaultPath::root(), settings);
3682 screen.rag_status = crate::rag::RagStatus::Online {
3683 llm_available: true,
3684 };
3685 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3686
3687 screen
3688 .handle_app_message(AppEvent::OpenDrawerView(DrawerView::Ask), &tx)
3689 .await;
3690 assert!(screen.panels.is_showing_ask());
3691 screen
3692 .panels
3693 .ask_mut()
3694 .thread_mut()
3695 .ask("a question?".into());
3696
3697 screen
3698 .open_path(VaultPath::note_path_from("alpha"), None, &tx)
3699 .await;
3700 assert!(!screen.panels.is_showing_ask());
3701
3702 screen
3704 .handle_app_message(AppEvent::OpenDrawerView(DrawerView::Ask), &tx)
3705 .await;
3706 assert!(
3707 screen.panels.is_showing_ask(),
3708 "a single reopen must not read as a toggle-off"
3709 );
3710 assert_eq!(
3711 screen.panels.ask().thread().turns().len(),
3712 1,
3713 "the thread survived the round trip"
3714 );
3715 }
3716
3717 #[tokio::test]
3723 async fn opening_an_attachment_over_ask_shows_it_and_keeps_the_thread() {
3724 let vault = crate::test_support::temp_vault("editor-ask-open-att").await;
3725 vault.validate_and_init().await.unwrap();
3726 let att_path = VaultPath::new("pic.png");
3727 vault
3728 .save_attachment(&att_path, b"\x89PNG\r\n\x1a\n")
3729 .await
3730 .unwrap();
3731 let settings = std::sync::Arc::new(std::sync::RwLock::new(
3732 crate::settings::AppSettings::default(),
3733 ));
3734 let mut screen = EditorScreen::new(vault, VaultPath::root(), settings);
3735 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3736
3737 screen.open_drawer_view(DrawerView::Ask, &tx);
3738 screen
3739 .panels
3740 .ask_mut()
3741 .thread_mut()
3742 .ask("a question?".into());
3743 assert!(screen.panels.is_showing_ask());
3744
3745 screen.open_attachment(att_path, &tx).await;
3747 assert!(
3748 !screen.panels.is_showing_ask(),
3749 "the attachment replaced Ask"
3750 );
3751
3752 screen.open_drawer_view(DrawerView::Ask, &tx);
3754 assert_eq!(
3755 screen.panels.ask().thread().turns().len(),
3756 1,
3757 "the thread survived opening the attachment"
3758 );
3759 }
3760
3761 #[tokio::test]
3765 async fn refresh_ask_capability_disables_without_a_client() {
3766 let (mut screen, _, _, _dir) = test_screen().await;
3767
3768 screen
3770 .ask
3771 .refresh_capability(&mut screen.panels, screen.rag_status)
3772 .await;
3773 assert!(
3774 !screen.panels.ask().has_client(),
3775 "the resident Ask panel loses its client without an LLM server"
3776 );
3777 }
3778
3779 #[tokio::test]
3782 async fn answer_lands_on_the_thread_while_browsing_files() {
3783 let (mut screen, _, _, _dir) = test_screen().await;
3784 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3785
3786 screen.open_drawer_view(DrawerView::Ask, &tx);
3787 let turn_id = screen.panels.ask_mut().thread_mut().ask("q?".into());
3788 screen.open_drawer_view(DrawerView::Files, &tx);
3790 assert!(!screen.panels.is_showing_ask());
3791
3792 screen.ask.handle_data(
3793 &mut screen.panels,
3794 crate::components::events::AskData::AnswerReady {
3795 turn_id,
3796 result: Ok(("the answer".into(), vec![])),
3797 },
3798 &tx,
3799 );
3800
3801 let thread = screen.panels.ask().thread();
3802 let turn = thread.turns().iter().find(|t| t.id == turn_id).unwrap();
3803 assert!(
3804 matches!(turn.status, crate::ask::TurnStatus::Done),
3805 "the answer completed the turn"
3806 );
3807 }
3808
3809 #[tokio::test]
3814 async fn sem_rail_entry_tracks_search_availability() {
3815 let (mut screen, _, _, _dir) = test_screen().await;
3816 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3817
3818 assert!(!screen.panels.rail_shows(DrawerView::Semantic));
3820 assert!(!screen.panels.rail_shows(DrawerView::Ask));
3821
3822 screen
3824 .handle_owned_message(
3825 AppEvent::RagStatus(crate::rag::RagStatus::Online {
3826 llm_available: false,
3827 }),
3828 &tx,
3829 )
3830 .await;
3831 assert!(screen.panels.rail_shows(DrawerView::Semantic));
3832 assert!(!screen.panels.rail_shows(DrawerView::Ask));
3833
3834 screen
3836 .handle_owned_message(AppEvent::RagStatus(crate::rag::RagStatus::Offline), &tx)
3837 .await;
3838 assert!(!screen.panels.rail_shows(DrawerView::Semantic));
3839
3840 screen
3842 .handle_owned_message(
3843 AppEvent::RagStatus(crate::rag::RagStatus::Online {
3844 llm_available: true,
3845 }),
3846 &tx,
3847 )
3848 .await;
3849 assert!(screen.panels.rail_shows(DrawerView::Semantic));
3850 assert!(screen.panels.rail_shows(DrawerView::Ask));
3851 }
3852
3853 #[tokio::test]
3859 async fn ask_focus_skips_the_llm_gate_when_already_showing_ask() {
3860 let (mut screen, _, _, _dir) = test_screen().await;
3861 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3862
3863 screen.rag_status = crate::rag::RagStatus::Online {
3864 llm_available: true,
3865 };
3866 screen.open_ask_workspace(&tx);
3867 assert!(screen.panels.is_showing_ask());
3868
3869 screen.rag_status = crate::rag::RagStatus::Offline;
3872
3873 screen.open_ask_workspace(&tx);
3874 assert!(screen.panels.is_showing_ask(), "still showing Ask");
3875 assert_eq!(screen.panels.focused(), PanelKind::Editor);
3876 assert!(
3877 rx.try_recv().is_err(),
3878 "no gate flash while Ask is already showing"
3879 );
3880 }
3881
3882 fn lay_out(screen: &mut EditorScreen) {
3894 let mut term = ratatui::Terminal::new(ratatui::backend::TestBackend::new(120, 40)).unwrap();
3895 term.draw(|f| screen.render(f)).unwrap();
3896 }
3897
3898 fn buffer_origin(screen: &EditorScreen) -> (u16, u16) {
3901 (0..40u16)
3902 .flat_map(|row| (0..120u16).map(move |col| (col, row)))
3903 .find(|(col, row)| screen.panels.is_note_buffer_cell(*col, *row))
3904 .expect("a laid-out screen always shows the note buffer")
3905 }
3906
3907 fn frame_corner(screen: &mut EditorScreen, tx: &AppTx) -> (u16, u16) {
3918 let (col, row) = buffer_origin(screen);
3919 let corner = (col - 1, row - 1);
3920 screen.panels.focus(PanelKind::Drawer);
3921 screen.handle_input(&press_at(corner.0, corner.1), tx);
3922 assert_eq!(
3923 screen.panels.focused(),
3924 PanelKind::Editor,
3925 "precondition: the frame is inside the editor column"
3926 );
3927 screen.clicks.end();
3929 corner
3930 }
3931
3932 fn press_at(col: u16, row: u16) -> InputEvent {
3933 use ratatui::crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
3934 InputEvent::Mouse(MouseEvent {
3935 kind: MouseEventKind::Down(MouseButton::Left),
3936 column: col,
3937 row,
3938 modifiers: KeyModifiers::NONE,
3939 })
3940 }
3941
3942 fn moved_at(col: u16, row: u16) -> InputEvent {
3943 use ratatui::crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
3944 InputEvent::Mouse(MouseEvent {
3945 kind: MouseEventKind::Moved,
3946 column: col,
3947 row,
3948 modifiers: KeyModifiers::NONE,
3949 })
3950 }
3951
3952 fn at(millis: u64) -> std::time::Instant {
3954 static ORIGIN: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
3955 *ORIGIN.get_or_init(std::time::Instant::now) + std::time::Duration::from_millis(millis)
3956 }
3957
3958 #[tokio::test]
3959 async fn two_presses_on_the_buffer_are_a_double_click() {
3960 let (mut screen, _, _, _dir) = test_screen().await;
3961 lay_out(&mut screen);
3962 let (col, row) = buffer_origin(&screen);
3963
3964 assert!(!screen.track_click(&press_at(col, row), at(0)));
3965 assert!(screen.track_click(&press_at(col, row), at(120)));
3966 }
3967
3968 #[tokio::test]
3974 async fn a_press_on_the_editor_frame_is_never_half_of_a_double() {
3975 let (mut screen, _, _, _dir) = test_screen().await;
3976 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3977 lay_out(&mut screen);
3978 let (col, row) = frame_corner(&mut screen, &tx);
3979
3980 assert!(!screen.track_click(&press_at(col, row), at(0)));
3981 assert!(
3982 !screen.track_click(&press_at(col, row), at(120)),
3983 "two clicks on the frame are not a follow"
3984 );
3985 }
3986
3987 #[tokio::test]
3991 async fn a_frame_press_separates_two_buffer_presses() {
3992 let (mut screen, _, _, _dir) = test_screen().await;
3993 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3994 lay_out(&mut screen);
3995 let (frame_col, frame_row) = frame_corner(&mut screen, &tx);
3996 let (col, row) = buffer_origin(&screen);
3997
3998 screen.track_click(&press_at(col, row), at(0));
3999 screen.track_click(&press_at(frame_col, frame_row), at(40));
4000 assert!(!screen.track_click(&press_at(col, row), at(80)));
4001 }
4002
4003 #[tokio::test]
4007 async fn motion_over_the_frame_does_not_break_a_double() {
4008 let (mut screen, _, _, _dir) = test_screen().await;
4009 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4010 lay_out(&mut screen);
4011 let (frame_col, frame_row) = frame_corner(&mut screen, &tx);
4012 let (col, row) = buffer_origin(&screen);
4013
4014 screen.track_click(&press_at(col, row), at(0));
4015 assert!(!screen.track_click(&moved_at(frame_col, frame_row), at(40)));
4016 assert!(screen.track_click(&press_at(col, row), at(120)));
4017 }
4018
4019 #[tokio::test]
4020 async fn a_key_between_presses_ends_the_click_run() {
4021 let (mut screen, _, _, _dir) = test_screen().await;
4022 lay_out(&mut screen);
4023 let (col, row) = buffer_origin(&screen);
4024
4025 screen.track_click(&press_at(col, row), at(0));
4026 assert!(!screen.track_click(&chr('x'), at(40)));
4027 assert!(!screen.track_click(&press_at(col, row), at(80)));
4028 }
4029
4030 #[tokio::test]
4035 async fn an_overlay_ends_the_click_run() {
4036 let (mut screen, _, _, _dir) = test_screen().await;
4037 lay_out(&mut screen);
4038 let (col, row) = buffer_origin(&screen);
4039 let vault = screen.vault.clone();
4040
4041 screen.track_click(&press_at(col, row), at(0));
4042 screen.present_overlay(Box::new(ActiveDialog::create_note(
4043 VaultPath::note_path_from("x"),
4044 vault,
4045 None,
4046 )));
4047 assert!(
4048 !screen.track_click(&press_at(col, row), at(40)),
4049 "a press under an overlay is the overlay's"
4050 );
4051 screen.dismiss_overlay();
4052 assert!(
4053 !screen.track_click(&press_at(col, row), at(80)),
4054 "and neither press survives to pair with the next one"
4055 );
4056 }
4057
4058 #[tokio::test]
4062 async fn a_double_click_on_a_wikilink_follows_it() {
4063 let (mut screen, _, _, _dir) = test_screen().await;
4064 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4065 screen
4066 .panels
4067 .editor_mut()
4068 .unwrap()
4069 .set_text("[[target note]]".to_string());
4070 lay_out(&mut screen);
4071 let (col, row) = buffer_origin(&screen);
4074 let (col, row) = (col + 6, row);
4075
4076 screen.handle_input(&press_at(col, row), &tx);
4077 screen.handle_input(&press_at(col, row), &tx);
4078
4079 let followed = std::iter::from_fn(|| rx.try_recv().ok())
4080 .find_map(|ev| match ev {
4081 AppEvent::FollowLink(target) => Some(target),
4082 _ => None,
4083 })
4084 .expect("the double-click emitted no FollowLink");
4085 assert_eq!(followed, "target note");
4086 }
4087
4088 #[tokio::test]
4091 async fn a_single_press_on_a_wikilink_only_places_the_cursor() {
4092 let (mut screen, _, _, _dir) = test_screen().await;
4093 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4094 screen
4095 .panels
4096 .editor_mut()
4097 .unwrap()
4098 .set_text("[[target note]]".to_string());
4099 lay_out(&mut screen);
4100 let (col, row) = buffer_origin(&screen);
4101
4102 screen.handle_input(&press_at(col + 6, row), &tx);
4103
4104 assert!(
4105 !std::iter::from_fn(|| rx.try_recv().ok())
4106 .any(|ev| matches!(ev, AppEvent::FollowLink(_))),
4107 "one click is a cursor placement, not a navigation"
4108 );
4109 assert!(
4110 screen
4111 .panels
4112 .editor()
4113 .unwrap()
4114 .follow_target_at_cursor()
4115 .is_some(),
4116 "but it did land on the link, so the next press has something to follow"
4117 );
4118 }
4119}
4120
4121#[cfg(test)]
4122mod sort_routing_tests {
4123 use super::*;
4124 use crate::app_screen::AppScreen;
4125 use crate::components::events::SortTarget;
4126 use crate::components::file_list::{SortField, SortOrder};
4127
4128 async fn make_editor() -> (
4129 EditorScreen,
4130 AppTx,
4131 tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
4132 ) {
4133 let vault = crate::test_support::temp_vault("editor-sort").await;
4134 vault.validate_and_init().await.unwrap();
4135 let settings = std::sync::Arc::new(std::sync::RwLock::new(
4136 crate::settings::AppSettings::default(),
4137 ));
4138 let screen = EditorScreen::new(vault, VaultPath::root(), settings);
4139 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
4140 (screen, tx, rx)
4141 }
4142
4143 #[tokio::test(flavor = "multi_thread")]
4144 async fn sort_save_default_persists_to_settings() {
4145 let (mut screen, tx, _rx) = make_editor().await;
4146 screen
4147 .handle_app_message(
4148 AppEvent::SortChanged {
4149 target: SortTarget::Sidebar,
4150 field: SortField::Title,
4151 order: SortOrder::Descending,
4152 group_directories: true,
4153 persist: true,
4154 },
4155 &tx,
4156 )
4157 .await;
4158 let s = screen.settings.read().unwrap();
4159 assert_eq!(
4160 s.default_sort_field,
4161 crate::settings::SortFieldSetting::Title
4162 );
4163 assert_eq!(
4164 s.default_sort_order,
4165 crate::settings::SortOrderSetting::Descending
4166 );
4167 assert!(s.group_directories);
4168 }
4169
4170 #[tokio::test(flavor = "multi_thread")]
4171 async fn sort_save_default_journal_dir_writes_journal_settings() {
4172 let (mut screen, tx, _rx) = make_editor().await;
4173 let journal = screen.vault.journal_path().clone();
4175 screen.panels.sidebar_mut().navigate(journal, &tx);
4176
4177 screen
4178 .handle_app_message(
4179 AppEvent::SortChanged {
4180 target: SortTarget::Sidebar,
4181 field: SortField::Title,
4182 order: SortOrder::Ascending,
4183 group_directories: false,
4184 persist: true,
4185 },
4186 &tx,
4187 )
4188 .await;
4189
4190 let s = screen.settings.read().unwrap();
4191 assert_eq!(
4192 s.journal_sort_field,
4193 crate::settings::SortFieldSetting::Title
4194 );
4195 assert_eq!(
4196 s.journal_sort_order,
4197 crate::settings::SortOrderSetting::Ascending
4198 );
4199 assert_eq!(
4201 s.default_sort_field,
4202 crate::settings::SortFieldSetting::Name
4203 );
4204 }
4205
4206 #[tokio::test(flavor = "multi_thread")]
4209 async fn sort_changed_without_persist_leaves_settings() {
4210 let (mut screen, tx, _rx) = make_editor().await;
4211 screen
4212 .handle_app_message(
4213 AppEvent::SortChanged {
4214 target: SortTarget::Sidebar,
4215 field: SortField::Title,
4216 order: SortOrder::Descending,
4217 group_directories: true,
4218 persist: false,
4219 },
4220 &tx,
4221 )
4222 .await;
4223 let s = screen.settings.read().unwrap();
4224 assert_eq!(
4225 s.default_sort_field,
4226 crate::settings::SortFieldSetting::Name
4227 );
4228 assert_eq!(
4229 s.default_sort_order,
4230 crate::settings::SortOrderSetting::Ascending
4231 );
4232 assert!(!s.group_directories);
4233 }
4234}