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;
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 {
369 let mut s = self.settings.write().unwrap();
370 s.add_path_history(&path);
371 }
372 let settings_snapshot = self.settings.read().unwrap().clone();
373 tokio::spawn(async move {
374 settings_snapshot.save_to_disk().ok();
375 });
376
377 self.path = path.clone();
378 self.hide_ask_if_shown();
383 self.panels.clear_attachment();
384 self.panels
386 .sidebar_mut()
387 .set_open_note(Some(self.path.clone()));
388 match self.vault.get_note_text(&self.path).await {
389 Ok(content) => {
390 self.doc_meta.note_opened(&self.path, tx);
391 if let Some(ed) = self.panels.editor_mut() {
392 ed.set_text(content);
393 if let Some(needles) = emphasis {
396 ed.set_search_needles(needles);
397 }
398 ed.set_redraw_tx(tx);
399 }
400 tx.send(AppEvent::Redraw).ok();
401 self.reflect_open_note_in_drawers(tx);
404 }
405 Err(e) => {
406 if matches!(e, VaultError::FSError(FSError::VaultPathNotFound { .. })) {
407 self.present_overlay(Box::new(ActiveDialog::create_note(
408 self.path.clone(),
409 self.vault.clone(),
410 None,
411 )));
412 } else {
413 tracing::error!("Failed to read note {}: {e}", self.path);
414 let parent = self.path.get_parent_path().0;
415 tx.send(AppEvent::OpenScreen(ScreenEvent::OpenBrowse(
416 self.vault.clone(),
417 parent,
418 )))
419 .ok();
420 }
421 return;
422 }
423 }
424
425 let note_parent = path.get_parent_path().0;
428 if self.panels.sidebar().is_empty() {
429 self.navigate_sidebar(note_parent, tx);
430 }
431
432 let interval = self.settings.read().unwrap().autosave_interval_secs;
434 self.autosave.restart(interval, tx.clone());
435 }
436
437 fn hide_ask_if_shown(&mut self) {
443 self.ask.hide_if_shown(&mut self.panels);
444 }
445
446 async fn open_attachment(&mut self, path: VaultPath, tx: &AppTx) {
452 self.try_save().await;
454 self.autosave_task.abort();
455
456 match self.vault.get_attachment_details(&path).await {
457 Ok(details) => {
458 let (icons, kb) = {
459 let s = self.settings.read().unwrap();
460 (s.icons(), s.key_bindings.clone())
461 };
462 let view = AttachmentView::new(details, icons, kb);
463 self.path = path;
464 self.hide_ask_if_shown();
467 self.panels.show_attachment(view);
468 self.panels.sidebar_mut().set_open_note(None);
469 tx.send(AppEvent::Redraw).ok();
470 }
471 Err(e) => {
472 self.footer
473 .flash(format!("Cannot open attachment: {e}"), tx);
474 }
475 }
476 }
477
478 fn navigate_sidebar(&mut self, dir: VaultPath, tx: &AppTx) {
479 self.panels.sidebar_mut().navigate(dir, tx);
483 }
484
485 fn refresh_sidebar_if_showing(&mut self, dir: &VaultPath, tx: &AppTx) {
491 self.panels.sidebar_mut().refresh_if_showing(dir, tx);
492 }
493
494 fn note_saved(&mut self, path: &VaultPath, raw_title: String) {
498 let title = FileListEntry::display_title(raw_title);
499 self.panels.sidebar_mut().update_note_row(path, &title);
500 }
501
502 async fn try_save(&mut self) {
503 if self.autosave_task.is_in_flight() {
520 match self.autosave_task.await_with_timeout(SAVE_TIMEOUT).await {
521 Some(_) => {} None => {
523 self.autosave_task.abort();
525 return;
526 }
527 }
528 }
529 let Some(text) = self
531 .panels
532 .editor()
533 .filter(|e| e.is_dirty())
534 .map(|e| e.get_text())
535 else {
536 return;
537 };
538 let save = self.vault.save_note(&self.path, &text);
542 if let Ok(Ok((_, content))) = tokio::time::timeout(SAVE_TIMEOUT, save).await {
543 if let Some(ed) = self.panels.editor_mut() {
544 ed.mark_saved(text);
545 }
546 let path = self.path.clone();
547 self.note_saved(&path, content.title);
548 }
549 }
550
551 fn spawn_autosave(&mut self, tx: &AppTx) {
560 if self.autosave_task.is_in_flight() {
563 return;
564 }
565 let Some(ed) = self.panels.editor() else {
566 return;
567 };
568 if !ed.is_dirty() {
569 return;
570 }
571 let text = ed.get_text();
572 let revision = ed.content_revision();
573 let vault = self.vault.clone();
574 let path = self.path.clone();
575 let tx = tx.clone();
576 self.autosave_task.spawn(async move {
577 let (saved_revision, title) = match vault.save_note(&path, &text).await {
578 Ok((_, content)) => (Some(revision), Some(content.title)),
579 Err(_) => (None, None),
580 };
581 let _ = tx.send(AppEvent::AutosaveCompleted {
582 path,
583 saved_revision,
584 title,
585 });
586 });
587 }
588
589 fn opener_focus(&self) -> PanelKind {
592 self.panels.focused()
593 }
594
595 fn present_overlay(&mut self, overlay: Box<dyn Overlay>) {
599 self.leader.cancel();
602 let opener = self.opener_focus();
603 self.overlays.open(overlay, opener);
604 }
605
606 fn dismiss_overlay(&mut self) {
612 if let Some(opener) = self.overlays.close() {
613 self.panels.focus(opener);
614 }
615 }
616
617 async fn on_entry_op(&mut self, from: VaultPath, tx: &AppTx) {
618 self.dismiss_overlay();
619 if from.is_like(&self.path) {
624 self.autosave.stop();
625 self.try_save().await;
626 let parent = self.path.get_parent_path().0;
627 tx.send(AppEvent::OpenScreen(ScreenEvent::OpenBrowse(
628 self.vault.clone(),
629 parent,
630 )))
631 .ok();
632 } else {
633 self.refresh_sidebar_if_showing(&from.get_parent_path().0, tx);
634 }
635 }
636
637 async fn on_note_renamed(&mut self, from: VaultPath, to: VaultPath, tx: &AppTx) {
643 self.dismiss_overlay();
644 self.panels.sidebar_mut().rename_note_row(&from, &to);
645 if from.is_like(&self.path) {
649 self.autosave_task.abort();
655 match self.vault.get_note_text(&to).await {
656 Ok(text) => {
657 self.path = to.clone();
658 if let Some(ed) = self.panels.editor_mut() {
659 ed.set_text(text.clone());
660 ed.mark_saved(text);
661 }
662 self.panels
663 .sidebar_mut()
664 .set_open_note(Some(self.path.clone()));
665 self.doc_meta.note_opened(&self.path, tx);
666 self.reflect_open_note_in_drawers(tx);
667 let interval = self.settings.read().unwrap().autosave_interval_secs;
669 self.autosave.restart(interval, tx.clone());
670 }
671 Err(_) => {
672 self.autosave.stop();
677 let parent = to.get_parent_path().0;
678 tx.send(AppEvent::OpenScreen(ScreenEvent::OpenBrowse(
679 self.vault.clone(),
680 parent,
681 )))
682 .ok();
683 }
684 }
685 }
686 }
687}
688
689impl EditorScreen {
690 fn input_ctx(&self, double_click: bool) -> InputCtx {
697 InputCtx {
698 overlay: self.overlays.active_kind(),
699 leader_pending: self.leader.is_pending(),
700 focused: self.panels.focused(),
701 drawer_view: self.panels.active_drawer_view(),
702 space_leads: self.panels.editor().is_some_and(|e| e.space_leads()),
703 claim: self.panels.editor().map(|e| e.claim()).unwrap_or_default(),
704 double_click,
705 }
706 }
707
708 fn track_click(&mut self, event: &InputEvent, now: std::time::Instant) -> bool {
732 let InputEvent::Mouse(mouse) = event else {
733 self.clicks.end();
734 return false;
735 };
736 if self.overlays.is_open() {
737 self.clicks.end();
738 return false;
739 }
740 let on_buffer = matches!(
745 mouse.kind,
746 ratatui::crossterm::event::MouseEventKind::Down(_)
747 ) && self.panels.is_note_buffer_cell(mouse.column, mouse.row);
748 self.clicks.observe(mouse, on_buffer, now)
749 }
750
751 fn apply_classification(
755 &mut self,
756 classification: Classification,
757 event: &InputEvent,
758 tx: &AppTx,
759 ) -> EventState {
760 if let Some(chord) = classification.flash {
761 self.footer.flash(chord, tx);
762 }
763 if classification.cancel_leader {
764 self.leader.cancel();
765 }
766 self.execute_intent(classification.intent, event, tx)
767 }
768
769 fn execute_intent(
770 &mut self,
771 intent: EditorIntent,
772 event: &InputEvent,
773 tx: &AppTx,
774 ) -> EventState {
775 match intent {
776 EditorIntent::Consume => EventState::Consumed,
777 EditorIntent::EditorPaste => {
778 if !self.try_paste_image(tx)
779 && let InputEvent::Paste(text) = event
780 && !text.is_empty()
781 && let Some(ed) = self.panels.editor_mut()
782 {
783 ed.paste_text(text, tx);
784 }
785 EventState::Consumed
786 }
787 EditorIntent::ImageProbe => {
788 if self.try_paste_image(tx) {
789 EventState::Consumed
790 } else {
791 let ctx = self.input_ctx(false);
797 let classification = {
798 let s = self.settings.read().unwrap();
799 crate::app_screen::editor_input::classify_tail(
800 event,
801 &s.key_bindings,
802 &ctx,
803 false,
804 )
805 };
806 self.apply_classification(classification, event, tx)
807 }
808 }
809 EditorIntent::FollowLink => {
810 if self.follow_link_at_cursor(tx) {
811 EventState::Consumed
812 } else if matches!(event, InputEvent::Mouse(_)) {
813 self.execute_intent(EditorIntent::Mouse, event, tx)
821 } else {
822 EventState::Consumed
823 }
824 }
825 EditorIntent::LeaderKey(key) => self.handle_leader_key(&key, tx),
826 EditorIntent::LeaderStart => {
827 self.leader.start();
828 self.schedule_whichkey_reveal(tx);
829 EventState::Consumed
830 }
831 EditorIntent::Op(op) => {
832 self.run_op(op, tx);
833 EventState::Consumed
834 }
835 EditorIntent::ToggleOverlay { kind, open } => {
836 if self.overlays.active_kind() == Some(kind) {
837 self.dismiss_overlay();
838 } else {
839 self.open_overlay(open, tx);
840 }
841 EventState::Consumed
842 }
843 EditorIntent::OpenOverlay(open) => {
844 self.open_overlay(open, tx);
845 EventState::Consumed
846 }
847 EditorIntent::Overlay => self.overlays.handle_input(event, tx),
848 EditorIntent::Mouse => {
849 let state = self.panels.handle_mouse(event, tx);
853 self.ask.sync_sources(&mut self.panels, tx);
855 if self.panels.editor().is_some_and(|e| e.wants_context_menu) {
859 if let Some(ed) = self.panels.editor_mut() {
860 ed.wants_context_menu = false;
861 }
862 tx.send(AppEvent::FileOp(FileOp::ShowMenu(self.path.clone())))
863 .ok();
864 }
865 state
866 }
867 EditorIntent::Panel { fallback } => {
868 let state = self.panels.handle_input(event, tx);
869 self.ask.sync_sources(&mut self.panels, tx);
872 if state == EventState::NotConsumed {
873 match fallback {
874 PanelFallback::None => state,
875 PanelFallback::FocusCycle(CycleDir::Right) => {
876 self.focus_right(tx);
877 EventState::Consumed
878 }
879 PanelFallback::FocusCycle(CycleDir::Left) => {
880 self.focus_left(tx);
881 EventState::Consumed
882 }
883 PanelFallback::FocusEditor => {
884 self.focus_editor();
885 EventState::Consumed
886 }
887 }
888 } else {
889 state
890 }
891 }
892 }
893 }
894
895 fn run_op(&mut self, op: EditorOp, tx: &AppTx) {
896 match op {
897 EditorOp::ToggleDrawer => self.toggle_drawer(tx),
898 EditorOp::FocusLeft => self.focus_left(tx),
899 EditorOp::FocusRight => self.focus_right(tx),
900 EditorOp::OpenJournal => {
901 tx.send(AppEvent::OpenJournal).ok();
902 }
903 EditorOp::ShowFileOps => {
904 tx.send(AppEvent::FileOp(FileOp::ShowMenu(self.path.clone())))
905 .ok();
906 }
907 EditorOp::ToggleQueryPanel => self.toggle_backlinks(tx),
908 EditorOp::OpenFileBrowserReveal => {
909 self.open_drawer_view(DrawerView::Files, tx);
910 self.reveal_note_dir_in_sidebar(tx);
911 }
912 EditorOp::SaveCurrentQuery => {
913 if let Some((query, provenance, source)) = self.save_query_source() {
914 self.present_overlay(Box::new(ActiveDialog::save_search(
918 query,
919 provenance,
920 source,
921 self.vault.clone(),
922 tx,
923 )));
924 }
925 }
926 EditorOp::FindInBuffer => {
927 if let Some(ed) = self.panels.editor_mut() {
928 ed.open_or_advance_search();
929 }
930 }
931 EditorOp::ReplaceInBuffer => {
932 if let Some(ed) = self.panels.editor_mut() {
933 ed.open_replace();
934 }
935 }
936 EditorOp::ApplyText(text_action) => {
937 if let Some(ed) = self.panels.editor_mut() {
938 ed.apply_text_action(text_action);
939 }
940 }
941 EditorOp::OpenAsk => self.open_ask_workspace(tx),
942 }
943 }
944
945 fn open_overlay(&mut self, open: OverlayOpen, tx: &AppTx) {
950 if self.overlays.is_open() {
951 return;
952 }
953 let overlay = self.build_overlay(open, tx);
954 self.present_overlay(overlay);
955 }
956
957 fn build_overlay(&self, open: OverlayOpen, tx: &AppTx) -> Box<dyn Overlay> {
962 let s = self.settings.read().unwrap();
963 match open {
964 OverlayOpen::SearchBrowser => {
967 let provider = resolving_search_source(
968 self.vault.clone(),
969 s.current_last_paths(),
970 Some(self.path.clone()),
971 );
972 Box::new(NoteBrowserModal::new(
973 "Note Browser",
974 BrowserScope::Query,
975 provider,
976 self.vault.clone(),
977 s.key_bindings.clone(),
978 s.icons(),
979 tx.clone(),
980 ))
981 }
982 OverlayOpen::FileFinder => {
985 let current_dir = self.path.get_parent_path().0;
986 let provider = FileFinderProvider::new(self.vault.clone(), current_dir);
987 Box::new(NoteBrowserModal::new(
988 "Find Note",
989 BrowserScope::Files,
990 provider,
991 self.vault.clone(),
992 s.key_bindings.clone(),
993 s.icons(),
994 tx.clone(),
995 ))
996 }
997 OverlayOpen::SavedSearches => Box::new(SavedSearchesModal::new(
999 self.vault.clone(),
1000 s.key_bindings.clone(),
1001 s.icons(),
1002 tx.clone(),
1003 )),
1004 OverlayOpen::CommandPalette => {
1006 let gateway = s
1007 .key_bindings
1008 .first_combo_for(&ActionShortcuts::Leader)
1009 .unwrap_or_else(|| "leader".to_string());
1010 Box::new(
1011 crate::components::command_palette::CommandPaletteModal::new(
1012 &s.leader_tree(),
1013 &gateway,
1014 s.icons(),
1015 tx.clone(),
1016 ),
1017 )
1018 }
1019 OverlayOpen::WorkspaceSwitcher => Box::new(ActiveDialog::workspace_switcher(&s)),
1021 OverlayOpen::ThemePicker => Box::new(ActiveDialog::theme_picker(&s)),
1024 OverlayOpen::Help => Box::new(ActiveDialog::help(&s.key_bindings)),
1025 OverlayOpen::QueryHelp => Box::new(ActiveDialog::query_syntax()),
1026 OverlayOpen::Cheatsheet => Box::new(ActiveDialog::cheatsheet(&s)),
1027 OverlayOpen::SortQuery => {
1028 let (field, order) = self.panels.query().current_order();
1029 Box::new(ActiveDialog::sort(SortTarget::Query, field, order, false))
1030 }
1031 OverlayOpen::SortSidebar => {
1032 let (field, order) = self.panels.sidebar().current_sort();
1033 Box::new(ActiveDialog::sort(
1034 SortTarget::Sidebar,
1035 field,
1036 order,
1037 self.panels.sidebar().group_dirs(),
1038 ))
1039 }
1040 OverlayOpen::QuickNote => Box::new(ActiveDialog::quick_note(self.vault.clone())),
1041 }
1042 }
1043
1044 fn handle_update(&mut self, flow: UpdateFlow, tx: &AppTx) {
1048 match flow {
1049 UpdateFlow::Available(status) => {
1050 self.update = Some(status);
1051 }
1052 UpdateFlow::ShowDialog => {
1053 if let Some(status) = self.update.clone() {
1054 self.present_overlay(Box::new(ActiveDialog::update(&status)));
1055 }
1056 }
1057 UpdateFlow::Dismiss(_) => {
1058 self.update = None;
1060 }
1061 UpdateFlow::Applied => {
1062 self.update = None;
1065 }
1066 UpdateFlow::Apply => {
1067 let tx2 = tx.clone();
1068 tx.send(AppEvent::FlashMessage("Downloading update…".into()))
1069 .ok();
1070 tokio::spawn(async move {
1071 let result = async {
1072 let latest = crate::update::latest_release().await?;
1073 crate::update::install(latest).await
1074 }
1075 .await;
1076 let msg = match result {
1077 Ok(()) => {
1078 tx2.send(AppEvent::Update(UpdateFlow::Applied)).ok();
1079 "Update installed — restart kimün to apply".to_string()
1080 }
1081 Err(e) => format!("Update failed: {e}"),
1082 };
1083 tx2.send(AppEvent::FlashMessage(msg)).ok();
1084 });
1085 }
1086 }
1087 }
1088
1089 async fn handle_file_op(&mut self, op: FileOp, tx: &AppTx) {
1092 match op {
1093 FileOp::ShowMenu(path) => {
1094 self.present_overlay(Box::new(ActiveDialog::file_ops_menu(path)));
1095 }
1096 FileOp::ShowDelete(path) => {
1097 self.present_overlay(Box::new(ActiveDialog::delete(path, self.vault.clone())));
1098 }
1099 FileOp::ShowRename(path) => {
1100 self.present_overlay(Box::new(ActiveDialog::rename(path, self.vault.clone())));
1101 }
1102 FileOp::ShowMove(path) => {
1103 self.present_overlay(Box::new(ActiveDialog::move_to(
1104 path,
1105 self.vault.clone(),
1106 tx,
1107 )));
1108 }
1109 FileOp::ShowCreateWithContent { path, content } => {
1110 let path = self.vault.free_note_path(&path).await;
1118 self.present_overlay(Box::new(ActiveDialog::create_note(
1119 path,
1120 self.vault.clone(),
1121 Some(content),
1122 )));
1123 }
1124 FileOp::Created(path) => {
1125 self.refresh_sidebar_if_showing(&path.get_parent_path().0, tx);
1129 }
1130 FileOp::Deleted(path) => {
1131 self.on_entry_op(path, tx).await;
1132 }
1133 FileOp::Renamed { from, to } => {
1134 if from.is_note() {
1137 self.on_note_renamed(from, to, tx).await;
1138 } else {
1139 self.on_entry_op(from, tx).await;
1140 }
1141 }
1142 FileOp::Moved { from, .. } => {
1143 self.on_entry_op(from, tx).await;
1144 }
1145 }
1146 }
1147
1148 fn handle_saved_search(&mut self, flow: SavedSearchFlow, tx: &AppTx) {
1150 match flow {
1151 SavedSearchFlow::Selected { query, name } => {
1152 self.overlays.close();
1156 self.apply_saved_search(query, name, tx);
1157 }
1158 SavedSearchFlow::Confirmed {
1159 name,
1160 query,
1161 source,
1162 } => {
1163 let vault = self.vault.clone();
1167 let tx = tx.clone();
1168 tokio::spawn(async move {
1169 match vault.save_search(&name, &query).await {
1170 Ok(()) => {
1171 tx.send(AppEvent::SavedSearch(SavedSearchFlow::Persisted {
1172 name,
1173 query,
1174 source,
1175 }))
1176 .ok();
1177 }
1178 Err(e) => {
1179 tracing::warn!("failed to save search '{}': {}", name, e);
1180 tx.send(AppEvent::SavedSearch(SavedSearchFlow::SaveFailed { name }))
1181 .ok();
1182 }
1183 }
1184 });
1185 }
1186 SavedSearchFlow::Persisted {
1193 name,
1194 query,
1195 source: SaveSource::QueryPanel,
1196 } if name.trim() != query.trim() => {
1197 self.panels.query_mut().repin_saved_search(name, &query);
1198 }
1199 SavedSearchFlow::Persisted { .. } => {}
1200 SavedSearchFlow::SaveFailed { name } => {
1201 self.footer
1202 .flash(format!("Failed to save search '{name}'"), tx);
1203 }
1204 }
1205 }
1206
1207 async fn handle_owned_message(&mut self, msg: AppEvent, tx: &AppTx) {
1210 match msg {
1211 AppEvent::RagStatus(status) => {
1212 let ask_was = self.rag_status.llm_available();
1213 let sem_was = self.rag_status.search_available();
1214 self.rag_status = status;
1215 let ask_now = status.llm_available();
1221 let sem_now = status.search_available();
1222 if ask_was != ask_now || sem_was != sem_now {
1223 let (kb, icons) = {
1224 let s = self.settings.read().unwrap();
1225 (s.key_bindings.clone(), s.icons())
1226 };
1227 self.panels.rebuild_rail(
1228 kb,
1229 icons,
1230 crate::components::activity_rail::RailCaps {
1231 semantic: sem_now,
1232 ask: ask_now,
1233 },
1234 );
1235 }
1236 if ask_was != ask_now {
1237 self.ask
1238 .refresh_capability(&mut self.panels, self.rag_status)
1239 .await;
1240 }
1241 }
1242 AppEvent::Ask(data) => self.ask.handle_data(&mut self.panels, data, tx),
1243 AppEvent::FlashMessage(msg) => {
1246 self.footer.flash(msg, tx);
1247 }
1248 AppEvent::ExecuteLeaderAction(action) => {
1249 if self.overlays.is_open() {
1250 tracing::warn!("ExecuteLeaderAction({action:?}) dropped: overlay open");
1253 } else {
1254 self.execute_leader_action(action, tx);
1255 }
1256 }
1257 AppEvent::ApplyTheme { theme, persist } => {
1258 {
1261 let mut s = self.settings.write().unwrap();
1262 s.set_theme(theme.name.clone());
1263 }
1264 self.theme = (*theme).adapt_to_terminal();
1265 if persist {
1266 let snapshot = self.settings.read().unwrap().clone();
1267 tokio::spawn(async move {
1268 snapshot.save_to_disk().ok();
1269 });
1270 }
1271 tx.send(AppEvent::Redraw).ok();
1272 }
1273 AppEvent::RunTagQuery(label) if !self.overlays.is_open() => {
1276 self.open_find_with_query(format!("#{label}"), None, tx);
1277 }
1278 AppEvent::JumpToHeading(heading) if !self.overlays.is_open() => {
1279 if let Some(ed) = self.panels.editor_mut() {
1280 ed.jump_to_heading(&heading);
1281 }
1282 self.focus_editor();
1283 }
1284 AppEvent::OpenDrawerView(view) => {
1285 if view == DrawerView::Semantic && !self.rag_status.search_available() {
1289 let msg = if crate::rag::rag_configured(&self.settings) {
1290 "Semantic search needs a reachable server with an embedder"
1291 } else {
1292 "Set kimun_server_url in config to use semantic search"
1293 };
1294 tx.send(AppEvent::FlashMessage(msg.into())).ok();
1295 }
1296 else if view == DrawerView::Ask && !self.rag_status.llm_available() {
1299 tx.send(AppEvent::FlashMessage(
1300 "Ask needs an LLM-configured server; this one is semantic-search only"
1301 .into(),
1302 ))
1303 .ok();
1304 }
1305 else if self.panels.is_visible(PanelKind::Drawer)
1308 && self.panels.active_drawer_view() == view
1309 {
1310 self.panels.hide(PanelKind::Drawer);
1311 } else {
1312 self.open_drawer_view(view, tx);
1313 }
1314 }
1315 AppEvent::CloseOverlay => {
1316 self.dismiss_overlay();
1322 }
1323 AppEvent::SortChanged {
1324 target,
1325 field,
1326 order,
1327 group_directories,
1328 persist,
1329 } => {
1330 match target {
1331 SortTarget::Sidebar if persist => {
1332 let is_journal = self.panels.sidebar().is_current_journal();
1337 self.panels
1338 .sidebar_mut()
1339 .save_default(field, order, group_directories);
1340 {
1341 let mut s = self.settings.write().unwrap();
1342 if is_journal {
1343 s.journal_sort_field =
1344 crate::settings::SortFieldSetting::from(field);
1345 s.journal_sort_order =
1346 crate::settings::SortOrderSetting::from(order);
1347 } else {
1348 s.default_sort_field =
1349 crate::settings::SortFieldSetting::from(field);
1350 s.default_sort_order =
1351 crate::settings::SortOrderSetting::from(order);
1352 }
1353 s.group_directories = group_directories;
1354 }
1355 let snapshot = self.settings.read().unwrap().clone();
1356 tokio::spawn(async move {
1357 snapshot.save_to_disk().ok();
1358 });
1359 }
1360 SortTarget::Sidebar => {
1361 self.panels
1362 .sidebar_mut()
1363 .apply_sort(field, order, group_directories)
1364 }
1365 SortTarget::Query => self.panels.query_mut().apply_sort(field, order, tx),
1368 }
1369 }
1370 AppEvent::Autosave => {
1371 self.spawn_autosave(tx);
1372 }
1373 AppEvent::AutosaveCompleted {
1374 path,
1375 saved_revision,
1376 title,
1377 } => {
1378 if path == self.path
1379 && let Some(rev) = saved_revision
1380 && let Some(ed) = self.panels.editor_mut()
1381 {
1382 ed.mark_saved_at_revision(rev);
1383 }
1384 if let Some(raw_title) = title {
1385 self.note_saved(&path, raw_title);
1386 }
1387 self.doc_meta.refresh_git(tx);
1390 }
1399 AppEvent::FocusSidebar => {
1400 self.focus_sidebar(tx);
1401 }
1402 AppEvent::FollowLink(target) => {
1403 self.follow_link(target, tx).await;
1404 }
1405 AppEvent::FollowLabel(name) => {
1406 let initial = format!("#{name}");
1407 let s = self.settings.read().unwrap();
1408 let provider = resolving_search_source(
1409 self.vault.clone(),
1410 s.current_last_paths(),
1411 Some(self.path.clone()),
1412 );
1413 let modal = NoteBrowserModal::with_initial_query(
1414 "Note Browser",
1415 BrowserScope::Query,
1416 provider,
1417 self.vault.clone(),
1418 s.key_bindings.clone(),
1419 s.icons(),
1420 tx.clone(),
1421 initial,
1422 );
1423 drop(s);
1424 self.present_overlay(Box::new(modal));
1425 }
1426 AppEvent::InsertAtCursor(text) if self.panels.focused() == PanelKind::Editor => {
1427 if let Some(ed) = self.panels.editor_mut() {
1428 ed.insert_at_cursor(&text, tx);
1429 }
1430 }
1431 _ => {}
1432 }
1433 }
1434
1435 pub fn focus_editor(&mut self) {
1436 self.panels.focus(PanelKind::Editor);
1437 }
1438
1439 fn drawer_open_on(&self, view: DrawerView) -> bool {
1441 self.panels.is_visible(PanelKind::Drawer) && self.panels.active_drawer_view() == view
1442 }
1443
1444 fn reflect_open_note_in_drawers(&mut self, tx: &AppTx) {
1449 let path = self.path.clone();
1450 if self.drawer_open_on(DrawerView::Find) {
1451 self.panels.query_mut().set_note(path.clone(), tx.clone());
1452 }
1453 if self.panels.is_visible(PanelKind::Drawer) {
1454 match self.panels.active_drawer_view() {
1455 DrawerView::Links => self.panels.links_mut().set_note(path.clone(), tx),
1456 DrawerView::Outline => self.panels.outline_mut().set_note(path, tx),
1457 _ => {}
1458 }
1459 }
1460 }
1461
1462 fn reveal_note_dir_in_sidebar(&mut self, tx: &AppTx) {
1466 let note_parent = self.path.get_parent_path().0;
1467 if self.panels.sidebar().is_empty()
1468 || !note_parent.is_like(self.panels.sidebar().current_dir())
1469 {
1470 self.navigate_sidebar(note_parent, tx);
1471 }
1472 }
1473
1474 pub fn focus_sidebar(&mut self, tx: &AppTx) {
1478 if !self.panels.is_visible(PanelKind::Drawer) {
1479 self.open_drawer_view(DrawerView::Files, tx);
1483 } else {
1484 self.panels.focus(PanelKind::Drawer);
1485 }
1486 }
1487
1488 fn open_drawer_view(&mut self, view: DrawerView, tx: &AppTx) {
1492 self.ask.transition(&mut self.panels, view, tx);
1496 let newly_shown = !self.drawer_open_on(view);
1497 self.panels.open_drawer_view(view);
1498 self.drawer_view_revealed(view, newly_shown, tx);
1499 if view == DrawerView::Ask {
1503 self.panels.focus(PanelKind::Editor);
1504 } else {
1505 self.panels.focus(PanelKind::Drawer);
1506 }
1507 }
1508
1509 fn ensure_ask_workspace(&mut self, tx: &AppTx) {
1513 if !self.panels.is_showing_ask() {
1514 self.open_drawer_view(DrawerView::Ask, tx);
1515 }
1516 }
1517
1518 fn with_ask_panel(&mut self, tx: &AppTx, f: impl FnOnce(&mut ThreadPanel, &AppTx)) {
1522 self.ensure_ask_workspace(tx);
1523 f(self.panels.ask_mut(), tx);
1524 }
1525
1526 fn drawer_view_revealed(&mut self, view: DrawerView, newly_shown: bool, tx: &AppTx) {
1534 match view {
1535 DrawerView::Find if newly_shown => {
1536 self.panels
1537 .query_mut()
1538 .set_note(self.path.clone(), tx.clone());
1539 }
1540 DrawerView::Files if newly_shown => {
1541 self.reveal_note_dir_in_sidebar(tx);
1542 }
1543 DrawerView::Config => {
1544 let info = {
1545 let s = self.settings.read().unwrap();
1546 let key_of = |a: &ActionShortcuts| {
1547 s.key_bindings
1548 .first_combo_for(a)
1549 .unwrap_or_else(|| "unbound".to_string())
1550 };
1551 crate::components::drawer::ConfigInfo {
1552 theme_name: s.get_theme().name,
1553 leader_key: key_of(&ActionShortcuts::Leader),
1554 preferences_key: key_of(&ActionShortcuts::OpenPreferences),
1555 leader_timeout_ms: s.leader_timeout_ms,
1556 config_path: s
1557 .config_file
1558 .as_ref()
1559 .map(|p| p.display().to_string())
1560 .unwrap_or_else(|| "default location".to_string()),
1561 }
1562 };
1563 self.panels.drawer_set_config_info(info);
1564 }
1565 DrawerView::Semantic => self.panels.semantic_mut().ensure_source(tx),
1566 DrawerView::Tags => self.panels.tags_mut().refresh(tx),
1567 DrawerView::Links => self.panels.links_mut().set_note(self.path.clone(), tx),
1568 DrawerView::Outline => self.panels.outline_mut().set_note(self.path.clone(), tx),
1569 _ => {}
1570 }
1571 }
1572
1573 fn focus_left(&mut self, _tx: &AppTx) {
1575 if let Some(kind) = self.panels.prev_kind() {
1576 self.panels.focus(kind);
1577 }
1578 }
1579
1580 fn focus_right(&mut self, _tx: &AppTx) {
1582 if let Some(kind) = self.panels.next_kind() {
1583 self.panels.focus(kind);
1584 }
1585 }
1586
1587 fn toggle_drawer(&mut self, tx: &AppTx) {
1594 if self.panels.is_visible(PanelKind::Drawer) {
1595 self.panels.hide(PanelKind::Drawer);
1596 } else {
1597 self.panels.show(PanelKind::Drawer);
1598 self.drawer_view_revealed(self.panels.active_drawer_view(), true, tx);
1599 }
1600 }
1601
1602 fn save_query_source(&self) -> Option<(String, Option<String>, SaveSource)> {
1608 let (query, provenance, source) = match self.overlays.active_kind() {
1609 Some(OverlayKind::NoteBrowser) => (
1610 self.overlays.active_query().unwrap_or_default().to_string(),
1611 self.overlays
1612 .active_saved_search_provenance()
1613 .map(str::to_string),
1614 SaveSource::NoteBrowser,
1615 ),
1616 None => (
1617 self.panels.query().active_query().to_string(),
1618 self.panels.query().saved_search_name().map(str::to_string),
1619 SaveSource::QueryPanel,
1620 ),
1621 Some(_) => return None,
1622 };
1623 if query.trim().is_empty() {
1624 None
1625 } else {
1626 Some((query, provenance, source))
1627 }
1628 }
1629
1630 fn schedule_whichkey_reveal(&self, tx: &AppTx) {
1635 let timeout = {
1636 let s = self.settings.read().unwrap();
1637 std::time::Duration::from_millis(s.leader_timeout_ms)
1638 };
1639 let tx2 = tx.clone();
1640 tokio::spawn(async move {
1641 tokio::time::sleep(timeout + std::time::Duration::from_millis(10)).await;
1642 let _ = tx2.send(AppEvent::Redraw);
1643 });
1644 }
1645
1646 fn open_ask_workspace(&mut self, tx: &AppTx) {
1655 if self.overlays.is_open() {
1656 return;
1657 }
1658 if !self.panels.is_showing_ask() {
1659 if !self.rag_status.llm_available() {
1660 tx.send(AppEvent::FlashMessage(
1661 "Ask needs an LLM-configured server; this one is semantic-search only".into(),
1662 ))
1663 .ok();
1664 return;
1665 }
1666 self.open_drawer_view(DrawerView::Ask, tx);
1667 }
1668 self.panels.ask_mut().focus_composer();
1669 self.panels.focus(PanelKind::Editor);
1670 }
1671
1672 fn follow_link_at_cursor(&mut self, tx: &AppTx) -> bool {
1680 use crate::components::text_editor::FollowTarget;
1681 if self.panels.is_showing_attachment() {
1684 self.open_attachment_externally(tx);
1685 return true;
1686 }
1687 let Some(editor) = self.panels.editor_mut() else {
1688 return false;
1689 };
1690 match editor.follow_target_at_cursor() {
1691 Some(FollowTarget::Link(target)) => {
1692 tx.send(AppEvent::FollowLink(target)).ok();
1693 true
1694 }
1695 Some(FollowTarget::Label(name)) => {
1696 tx.send(AppEvent::FollowLabel(name)).ok();
1697 true
1698 }
1699 None => false,
1700 }
1701 }
1702
1703 fn open_attachment_externally(&mut self, tx: &AppTx) {
1706 let Some(path) = self.panels.attachment_path() else {
1707 return;
1708 };
1709 let os_path = self.vault.path_to_pathbuf(path);
1710 match open::that_detached(&os_path) {
1711 Ok(()) => self
1712 .footer
1713 .flash(format!("Opening {}", os_path.display()), tx),
1714 Err(e) => self.footer.flash(format!("Cannot open: {e}"), tx),
1715 }
1716 }
1717
1718 fn handle_leader_key(
1723 &mut self,
1724 key: &ratatui::crossterm::event::KeyEvent,
1725 tx: &AppTx,
1726 ) -> EventState {
1727 use ratatui::crossterm::event::{KeyCode, KeyModifiers};
1728 match key.code {
1729 KeyCode::Esc => {
1730 self.leader.cancel();
1731 self.focus_editor();
1732 }
1733 KeyCode::Backspace => {
1734 let outcome = self.leader.step_up();
1735 if outcome == LeaderOutcome::SteppedUp {
1737 self.schedule_whichkey_reveal(tx);
1738 }
1739 }
1740 KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
1741 match self.leader.feed(c) {
1742 LeaderOutcome::Fired(action) => self.execute_leader_action(action, tx),
1743 LeaderOutcome::Invalid => {
1744 self.footer.flash(format!("leader: no entry for '{c}'"), tx);
1746 self.schedule_whichkey_reveal(tx);
1747 }
1748 LeaderOutcome::Descended => self.schedule_whichkey_reveal(tx),
1749 _ => {}
1750 }
1751 }
1752 _ => {}
1756 }
1757 tx.send(AppEvent::Redraw).ok();
1758 EventState::Consumed
1759 }
1760
1761 fn execute_leader_action(&mut self, action: LeaderAction, tx: &AppTx) {
1764 match action {
1765 LeaderAction::OpenDrawer(view) => self.open_drawer_view(view, tx),
1766
1767 LeaderAction::AppCheckUpdates => {
1768 if let Some(status) = self.update.clone() {
1769 self.present_overlay(Box::new(ActiveDialog::update(&status)));
1770 } else {
1771 let tx2 = tx.clone();
1773 tx.send(AppEvent::FlashMessage("Checking for updates…".into()))
1774 .ok();
1775 tokio::spawn(async move {
1776 let Ok(config_dir) = crate::settings::config_dir() else {
1777 return;
1778 };
1779 match crate::update::check_now(config_dir, true).await {
1780 Ok(Some(status)) if status.update_available => {
1781 tx2.send(AppEvent::Update(UpdateFlow::Available(status)))
1785 .ok();
1786 tx2.send(AppEvent::Update(UpdateFlow::ShowDialog)).ok();
1787 }
1788 Ok(_) => {
1789 tx2.send(AppEvent::FlashMessage("kimün is up to date".into()))
1790 .ok();
1791 }
1792 Err(e) => {
1793 tx2.send(AppEvent::FlashMessage(format!(
1794 "Update check failed: {e}"
1795 )))
1796 .ok();
1797 }
1798 }
1799 });
1800 }
1801 }
1802
1803 LeaderAction::FindFiles => self.open_overlay(OverlayOpen::FileFinder, tx),
1806 LeaderAction::FindGrep => self.open_overlay(OverlayOpen::SearchBrowser, tx),
1807 LeaderAction::FindTags => self.open_drawer_view(DrawerView::Tags, tx),
1808 LeaderAction::FindBacklinks => {
1809 self.open_find_with_query("<{note}".to_string(), None, tx)
1810 }
1811 LeaderAction::FindSaved => self.open_overlay(OverlayOpen::SavedSearches, tx),
1812 LeaderAction::FindRecent => self.open_overlay(OverlayOpen::SearchBrowser, tx),
1813 LeaderAction::FindHeadings => self.open_drawer_view(DrawerView::Outline, tx),
1814
1815 LeaderAction::NoteNew => {
1817 self.open_drawer_view(DrawerView::Files, tx);
1821 self.footer
1822 .flash("type a name — Enter creates".to_string(), tx);
1823 }
1824 LeaderAction::NoteDaily => {
1825 tx.send(AppEvent::OpenJournal).ok();
1826 }
1827 LeaderAction::NoteFromTemplate => {
1828 self.footer.flash("templates — coming soon".to_string(), tx);
1829 }
1830 LeaderAction::NoteRename => {
1831 tx.send(AppEvent::FileOp(FileOp::ShowRename(self.path.clone())))
1832 .ok();
1833 }
1834 LeaderAction::NoteMove => {
1835 tx.send(AppEvent::FileOp(FileOp::ShowMove(self.path.clone())))
1836 .ok();
1837 }
1838 LeaderAction::NoteDelete => {
1839 tx.send(AppEvent::FileOp(FileOp::ShowDelete(self.path.clone())))
1840 .ok();
1841 }
1842
1843 LeaderAction::LinksTab(tab) => {
1845 self.open_drawer_view(DrawerView::Links, tx);
1846 self.panels.links_mut().show_tab(tab, tx);
1847 }
1848 LeaderAction::LinksGraph => {
1849 self.footer
1850 .flash("local graph — coming soon".to_string(), tx);
1851 }
1852
1853 LeaderAction::GitStatus => {
1856 self.doc_meta.refresh_git(tx);
1857 let msg = self
1858 .doc_meta
1859 .git()
1860 .cloned()
1861 .unwrap_or_else(|| "not a git repository".to_string());
1862 self.footer.flash(msg, tx);
1863 }
1864 LeaderAction::GitSync | LeaderAction::GitLog | LeaderAction::GitDiff => {
1865 self.footer
1866 .flash("git is display-only for now".to_string(), tx);
1867 }
1868
1869 LeaderAction::VaultSwitch => self.open_overlay(OverlayOpen::WorkspaceSwitcher, tx),
1871 LeaderAction::VaultReindex => {
1872 let vault = self.vault.clone();
1875 let tx2 = tx.clone();
1876 self.footer.flash("reindexing…".to_string(), tx);
1877 tokio::spawn(async move {
1878 let started = std::time::Instant::now();
1879 let result = vault.index_notes(kimun_core::NotesValidation::Fast).await;
1880 let msg = match result {
1881 Ok(_) => format!("reindexed in {:.1?}", started.elapsed()),
1882 Err(e) => format!("reindex failed: {e}"),
1883 };
1884 tx2.send(AppEvent::FlashMessage(msg)).ok();
1885 });
1886 }
1887 LeaderAction::VaultConfig => self.open_drawer_view(DrawerView::Config, tx),
1890 LeaderAction::VaultTheme => self.open_overlay(OverlayOpen::ThemePicker, tx),
1891 LeaderAction::VaultPreferences => {
1892 tx.send(AppEvent::OpenScreen(ScreenEvent::OpenPreferences))
1893 .ok();
1894 }
1895 LeaderAction::AppOnboarding => {
1896 tx.send(AppEvent::OpenScreen(ScreenEvent::OpenOnboarding))
1897 .ok();
1898 }
1899
1900 LeaderAction::WindowZen => {
1902 self.panels.hide(PanelKind::Drawer);
1903 self.focus_editor();
1904 }
1905 LeaderAction::WindowSplit => {
1906 self.footer
1907 .flash("editor splits — coming soon".to_string(), tx);
1908 }
1909 LeaderAction::WindowGrowDrawer => self.panels.adjust_drawer_width(4),
1910 LeaderAction::WindowShrinkDrawer => self.panels.adjust_drawer_width(-4),
1911
1912 LeaderAction::NoteToggleTodo => {
1914 self.footer
1915 .flash("toggle todo — coming soon".to_string(), tx);
1916 }
1917 LeaderAction::NotePreview => {
1918 self.footer
1919 .flash("preview — lands with phase 09".to_string(), tx);
1920 }
1921 LeaderAction::NoteCopyWikilink => {
1922 let link = format!("[[{}]]", self.path.get_clean_name());
1923 crate::components::yank(link, "wikilink copied", tx);
1924 }
1925 LeaderAction::NoteExport => {
1926 self.footer.flash("export — coming soon".to_string(), tx);
1927 }
1928 LeaderAction::NoteYankPath => {
1929 let path = self.path.to_string();
1930 crate::components::yank(path, "note path copied", tx);
1931 }
1932
1933 LeaderAction::AskFocus => self.open_ask_workspace(tx),
1935 LeaderAction::AskNew => {
1936 self.with_ask_panel(tx, |panel, _tx| {
1937 panel.thread_mut().clear();
1938 panel.focus_composer();
1939 });
1940 self.panels.ask_sources_mut().reset(tx);
1941 }
1942 LeaderAction::AskCopy => {
1943 self.with_ask_panel(tx, |panel, tx| panel.copy_selected(tx));
1944 }
1945 LeaderAction::AskSave => {
1946 self.with_ask_panel(tx, |panel, tx| panel.save_selected(tx));
1947 }
1948 LeaderAction::AskRegenerate => {
1949 self.with_ask_panel(tx, |panel, tx| panel.regenerate_selected(tx));
1950 self.ask.sync_sources(&mut self.panels, tx);
1951 }
1952 LeaderAction::AskSource => {
1953 self.ensure_ask_workspace(tx);
1954 self.ask.sync_sources_from_selected(&mut self.panels, tx);
1957 self.panels.ask_sources_mut().open_reader(0, tx);
1958 self.panels.focus(PanelKind::Drawer);
1959 }
1960
1961 LeaderAction::Palette => self.open_overlay(OverlayOpen::CommandPalette, tx),
1962 LeaderAction::Help => self.open_overlay(OverlayOpen::Cheatsheet, tx),
1963
1964 LeaderAction::NoteSave => {
1965 self.spawn_autosave(tx);
1968 }
1969 LeaderAction::AppQuit => {
1970 tx.send(AppEvent::Quit).ok();
1971 }
1972 }
1973 }
1974
1975 fn open_find_with_query(&mut self, query: String, name: Option<String>, tx: &AppTx) {
1985 self.panels.open_drawer_view(DrawerView::Find);
1986 self.panels.query_mut().apply_query(query, name, tx.clone());
1987 self.panels.focus(PanelKind::Drawer);
1988 }
1989
1990 fn apply_saved_search(&mut self, query: String, name: String, tx: &AppTx) {
1991 self.open_find_with_query(query, Some(name), tx);
1996 }
1997
1998 fn toggle_backlinks(&mut self, tx: &AppTx) {
1999 if self.drawer_open_on(DrawerView::Find) {
2000 self.panels.hide(PanelKind::Drawer);
2001 } else {
2002 self.open_drawer_view(DrawerView::Find, tx);
2003 }
2004 }
2005}
2006
2007#[async_trait]
2008impl AppScreen for EditorScreen {
2009 fn get_kind(&self) -> ScreenKind {
2010 ScreenKind::Editor
2011 }
2012
2013 async fn on_enter(&mut self, tx: &AppTx) {
2014 self.app_tx = Some(tx.clone());
2015 self.open_path(self.path.clone(), None, tx).await;
2016 }
2017
2018 fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
2019 let double_click = self.track_click(event, std::time::Instant::now());
2027 let ctx = self.input_ctx(double_click);
2028 let classification = if matches!(event, InputEvent::Key(_)) {
2032 let s = self.settings.read().unwrap();
2033 classify(event, &s.key_bindings, &ctx)
2034 } else {
2035 classify(event, &KeyBindings::empty(), &ctx)
2036 };
2037 self.apply_classification(classification, event, tx)
2038 }
2039
2040 fn render(&mut self, f: &mut ratatui::Frame) {
2041 let theme = &self.theme;
2042 f.render_widget(
2043 ratatui::widgets::Block::default().style(theme.base_style()),
2044 f.area(),
2045 );
2046
2047 let rows = Layout::default()
2048 .direction(Direction::Vertical)
2049 .constraints([
2050 Constraint::Length(1),
2051 Constraint::Min(0),
2052 Constraint::Length(crate::components::footer_bar::STATUS_BAR_HEIGHT),
2053 ])
2054 .split(f.area());
2055
2056 let workspace_badge = {
2060 let s = self.settings.read().unwrap();
2061 s.workspace_config.as_ref().map(|wc| {
2062 ratatui::text::Line::from(vec![
2063 ratatui::text::Span::styled(
2064 self.icons.workspace,
2065 Style::default().fg(theme.accent.to_ratatui()),
2066 ),
2067 ratatui::text::Span::styled(
2068 format!(" {}", wc.global.current_workspace),
2069 Style::default().fg(theme.gray.to_ratatui()),
2070 ),
2071 ])
2072 })
2073 };
2074 let workspace_label_width = workspace_badge
2075 .as_ref()
2076 .map(ratatui::text::Line::width)
2077 .unwrap_or_default();
2078 let breadcrumb = self
2079 .path
2080 .to_string()
2081 .trim_start_matches('/')
2082 .replace('/', " / ");
2083 let title_cols = Layout::default()
2084 .direction(Direction::Horizontal)
2085 .constraints([
2086 Constraint::Min(0),
2087 Constraint::Length(workspace_label_width as u16 + 2),
2088 ])
2089 .split(rows[0]);
2090 f.render_widget(
2091 Paragraph::new(ratatui::text::Line::from(vec![
2092 ratatui::text::Span::styled(
2093 " Kimün ",
2094 Style::default()
2095 .fg(theme.accent.to_ratatui())
2096 .add_modifier(ratatui::style::Modifier::BOLD),
2097 ),
2098 ratatui::text::Span::styled(
2099 format!("─ {breadcrumb}"),
2100 Style::default().fg(theme.fg_secondary.to_ratatui()),
2101 ),
2102 ])),
2103 title_cols[0],
2104 );
2105 if let Some(badge) = workspace_badge {
2106 f.render_widget(
2107 Paragraph::new(badge).alignment(ratatui::layout::Alignment::Right),
2108 title_cols[1],
2109 );
2110 }
2111
2112 self.panels
2115 .render(f, rows[1], theme, !self.overlays.is_open());
2116
2117 let (focus_label, hints) = if let Some(kind) = self.overlays.active_kind() {
2121 (kind.label(), self.overlays.hint_shortcuts())
2122 } else {
2123 (self.panels.focused_label(), self.panels.focused_hints())
2124 };
2125 let editing = if let Some(kind) = self.overlays.active_kind() {
2126 !matches!(kind, OverlayKind::Dialog)
2129 } else {
2130 match self.panels.focused() {
2131 PanelKind::Editor => true,
2132 PanelKind::Drawer => self.panels.drawer_is_text_input(),
2133 PanelKind::Rail => false,
2134 }
2135 };
2136 let path_str = self.path.to_string();
2137 let link_segment = if self.panels.focused() == PanelKind::Editor && !self.overlays.is_open()
2140 {
2141 let link = self
2142 .panels
2143 .editor()
2144 .and_then(|e| e.follow_target_at_cursor());
2145 self.doc_meta
2146 .link_segment(link.as_ref(), &self.path, self.app_tx.as_ref())
2147 } else {
2148 None
2149 };
2150 let ln_col = (self.panels.focused() == PanelKind::Editor && !self.overlays.is_open())
2153 .then(|| {
2154 self.panels.editor().map(|e| {
2155 let (row, col) = e.cursor_pos();
2156 (row + 1, col + 1)
2157 })
2158 })
2159 .flatten();
2160 let matches = (self.panels.focused() == PanelKind::Drawer
2162 && self.panels.active_drawer_view() == DrawerView::Find)
2163 .then(|| self.panels.query().result_count());
2164 let (global_hints, leader_timeout, gateway_label) = {
2165 let s = self.settings.read().unwrap();
2166 (
2167 crate::components::hints::global_hints(&s.key_bindings),
2168 std::time::Duration::from_millis(s.leader_timeout_ms),
2169 s.key_bindings
2170 .first_combo_for(&ActionShortcuts::Leader)
2171 .unwrap_or_else(|| "leader".to_string()),
2172 )
2173 };
2174 let ctx = crate::components::footer_bar::StatusContext {
2175 focus_label,
2176 editing,
2177 hints: &hints,
2178 global_hints: &global_hints,
2179 doc: crate::components::footer_bar::DocState {
2180 path: &path_str,
2181 dirty: self.panels.editor().is_some_and(|e| e.is_dirty()),
2182 ln_col,
2183 backlinks: self.doc_meta.backlinks(),
2184 git: self.doc_meta.git().cloned(),
2185 matches,
2186 link: link_segment,
2187 update: self
2188 .update
2189 .as_ref()
2190 .map(|u| format!("⬆ {} available", u.latest)),
2191 rag: self.rag_status.label().map(|s| s.to_string()),
2192 },
2193 };
2194 self.footer.render(f, rows[2], theme, &ctx);
2195
2196 let whichkey_visible = self
2199 .leader
2200 .pending_since()
2201 .is_some_and(|since| since.elapsed() >= leader_timeout);
2202 if whichkey_visible {
2203 let gateway = gateway_label;
2204 let area = f.area();
2205 let h = crate::components::which_key::desired_height(&self.leader, area.width)
2206 .min(rows[1].height);
2207 let rect =
2208 ratatui::layout::Rect::new(area.x, rows[2].y.saturating_sub(h), area.width, h);
2209 crate::components::which_key::render(f, rect, theme, &self.leader, &gateway);
2210 }
2211
2212 self.overlays.render(f, f.area(), &self.theme);
2214 }
2215
2216 async fn handle_app_message(&mut self, msg: AppEvent, tx: &AppTx) {
2217 match msg {
2218 AppEvent::OverlayData(data) => {
2223 self.overlays.handle_data(&data, &self.vault, tx);
2224 }
2225 AppEvent::Update(flow) => self.handle_update(flow, tx),
2226 AppEvent::FileOp(op) => self.handle_file_op(op, tx).await,
2227 AppEvent::SavedSearch(flow) => self.handle_saved_search(flow, tx),
2228 msg => {
2229 let Some(msg) = self.doc_meta.handle(msg, &self.path) else {
2232 return;
2233 };
2234 self.handle_owned_message(msg, tx).await;
2235 }
2236 }
2237 }
2238
2239 async fn try_open_path(
2242 &mut self,
2243 path: VaultPath,
2244 emphasis: Option<Vec<String>>,
2245 tx: &AppTx,
2246 ) -> Option<VaultPath> {
2247 self.dismiss_overlay();
2248 if path.is_note() {
2249 self.open_path(path, emphasis, tx).await;
2250 self.focus_editor();
2251 } else {
2252 self.navigate_sidebar(path, tx);
2253 }
2254 None
2255 }
2256
2257 async fn try_open_attachment(&mut self, path: VaultPath, tx: &AppTx) -> Option<VaultPath> {
2258 self.dismiss_overlay();
2259 self.open_attachment(path, tx).await;
2260 None
2261 }
2262
2263 async fn on_exit(&mut self, _tx: &AppTx) {
2264 self.try_save().await;
2265 }
2266}
2267
2268#[cfg(test)]
2269mod tests {
2270 use super::*;
2271 use crate::keys::key_event_to_combo;
2272
2273 #[test]
2275 fn panel_kind_labels_and_overlay_kind_compile() {
2276 assert_eq!(PanelKind::Editor.label(), "EDITOR");
2277 assert_eq!(PanelKind::Rail.label(), "RAIL");
2278 assert_eq!(DrawerView::Files.label(), "FILES");
2279 assert_eq!(DrawerView::Find.label(), "FIND");
2280 let _kind = OverlayKind::Dialog;
2281 }
2282
2283 async fn test_screen() -> (
2286 EditorScreen,
2287 Arc<NoteVault>,
2288 SharedSettings,
2289 tempfile::TempDir,
2290 ) {
2291 use crate::settings::AppSettings;
2292 use kimun_core::VaultConfig;
2293 use std::sync::RwLock;
2294
2295 let dir = tempfile::TempDir::new().unwrap();
2296 let vault = Arc::new(
2297 NoteVault::new(VaultConfig::new(crate::test_support::sys(dir.path())))
2298 .await
2299 .unwrap(),
2300 );
2301 let settings: SharedSettings = Arc::new(RwLock::new(AppSettings::default()));
2302 let screen = EditorScreen::new(vault.clone(), VaultPath::root(), settings.clone());
2303 (screen, vault, settings, dir)
2304 }
2305
2306 fn key_event(code: ratatui::crossterm::event::KeyCode) -> InputEvent {
2307 use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
2308 InputEvent::Key(KeyEvent::new(code, KeyModifiers::NONE))
2309 }
2310
2311 fn ctrl_key(c: char) -> InputEvent {
2312 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2313 InputEvent::Key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL))
2314 }
2315
2316 fn chr(c: char) -> InputEvent {
2317 key_event(ratatui::crossterm::event::KeyCode::Char(c))
2318 }
2319
2320 #[tokio::test]
2323 async fn leader_sequence_opens_drawer_view() {
2324 let (mut screen, _, _, _dir) = test_screen().await;
2325 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2326
2327 screen.panels.open_drawer_view(DrawerView::Tags);
2329
2330 screen.handle_input(&ctrl_key('g'), &tx);
2331 assert!(screen.leader.is_pending());
2332 screen.handle_input(&chr('o'), &tx);
2333 screen.handle_input(&chr('f'), &tx);
2334
2335 assert!(!screen.leader.is_pending());
2336 assert_eq!(screen.panels.active_drawer_view(), DrawerView::Files);
2337 assert_eq!(screen.panels.focused(), PanelKind::Drawer);
2338 }
2339
2340 #[tokio::test]
2343 async fn opening_files_drawer_reveals_current_note_dir() {
2344 let (mut screen, _, _, _dir) = test_screen().await;
2345 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2346
2347 screen.path = VaultPath::new("projects").append(&VaultPath::note_path_from("plan"));
2348 screen
2350 .panels
2351 .sidebar_mut()
2352 .navigate(VaultPath::new("other"), &tx);
2353 screen.panels.hide(PanelKind::Drawer);
2354
2355 screen.open_drawer_view(DrawerView::Files, &tx);
2356
2357 assert!(
2358 screen
2359 .panels
2360 .sidebar()
2361 .current_dir()
2362 .is_like(&VaultPath::new("projects"))
2363 );
2364 }
2365
2366 #[tokio::test]
2370 async fn file_browser_shortcut_rereveals_note_dir_when_already_open() {
2371 let (mut screen, _, _, _dir) = test_screen().await;
2372 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2373
2374 screen.path = VaultPath::new("projects").append(&VaultPath::note_path_from("plan"));
2375 screen.open_drawer_view(DrawerView::Files, &tx);
2376 screen
2378 .panels
2379 .sidebar_mut()
2380 .navigate(VaultPath::new("other"), &tx);
2381
2382 screen.handle_input(&ctrl_key('e'), &tx);
2383
2384 assert!(
2385 screen
2386 .panels
2387 .sidebar()
2388 .current_dir()
2389 .is_like(&VaultPath::new("projects"))
2390 );
2391 }
2392
2393 #[tokio::test]
2396 async fn leader_consumes_keys_while_editor_focused() {
2397 let (mut screen, _, _, _dir) = test_screen().await;
2398 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2399
2400 screen.panels.editor_mut().unwrap().set_text(String::new());
2401 assert_eq!(screen.panels.focused(), PanelKind::Editor);
2402
2403 screen.handle_input(&ctrl_key('g'), &tx);
2404 assert!(screen.leader.is_pending());
2405 screen.handle_input(&chr('w'), &tx);
2407 screen.handle_input(&chr('z'), &tx); assert_eq!(screen.panels.editor().unwrap().get_text(), "");
2409 assert!(!screen.panels.is_visible(PanelKind::Drawer));
2410 }
2411
2412 #[tokio::test]
2414 async fn leader_esc_cancels_and_focuses_editor() {
2415 let (mut screen, _, _, _dir) = test_screen().await;
2416 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2417
2418 screen.panels.focus(PanelKind::Rail);
2419 screen.handle_input(&ctrl_key('g'), &tx);
2420 screen.handle_input(&chr('f'), &tx);
2421 screen.handle_input(&key_event(ratatui::crossterm::event::KeyCode::Esc), &tx);
2422
2423 assert!(!screen.leader.is_pending());
2424 assert_eq!(screen.panels.focused(), PanelKind::Editor);
2425 }
2426
2427 #[tokio::test]
2431 async fn space_never_leads() {
2432 let (mut screen, _, _, _dir) = test_screen().await;
2433 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2434
2435 screen.panels.editor_mut().unwrap().set_text(String::new());
2437 screen.handle_input(&chr(' '), &tx);
2438 assert!(!screen.leader.is_pending());
2439 assert_eq!(screen.panels.editor().unwrap().get_text(), " ");
2440
2441 screen.panels.focus(PanelKind::Rail);
2443 screen.handle_input(&chr(' '), &tx);
2444 assert!(!screen.leader.is_pending());
2445
2446 screen.panels.open_drawer_view(DrawerView::Find);
2448 screen.panels.focus(PanelKind::Drawer);
2449 screen.handle_input(&chr(' '), &tx);
2450 assert!(!screen.leader.is_pending());
2451 }
2452
2453 #[tokio::test]
2454 async fn persist_saved_search_writes_via_core() {
2455 let (screen, _, _, _dir) = test_screen().await;
2456
2457 screen.persist_saved_search("t", "#todo").await.unwrap();
2458
2459 let all = screen.vault.list_saved_searches().await.unwrap();
2460 assert!(all.iter().any(|s| s.name == "t" && s.query == "#todo"));
2461 }
2462
2463 #[tokio::test]
2464 async fn applying_saved_search_sets_panel_query_and_focuses_it() {
2465 let (mut screen, _, _, _dir) = test_screen().await;
2466
2467 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2468 screen.apply_saved_search(
2469 "<{note}".to_string(),
2470 "Backlinks (current note)".to_string(),
2471 &tx,
2472 );
2473 assert!(screen.panels.is_visible(PanelKind::Drawer));
2474 assert_eq!(screen.panels.active_drawer_view(), DrawerView::Find);
2475 assert_eq!(screen.panels.query().active_query(), "<{note}");
2476 assert_eq!(screen.panels.focused(), PanelKind::Drawer);
2477 }
2478
2479 #[tokio::test]
2480 async fn saved_search_persisted_repins_panel_breadcrumb() {
2481 let (mut screen, _, _, _dir) = test_screen().await;
2482
2483 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2484 screen.apply_saved_search("#todo".to_string(), "todo".to_string(), &tx);
2485 screen
2486 .panels
2487 .query_mut()
2488 .set_active_query("#todo and #urgent".to_string());
2489
2490 screen
2491 .handle_app_message(
2492 AppEvent::SavedSearch(SavedSearchFlow::Persisted {
2493 name: "urgent-todos".to_string(),
2494 query: "#todo and #urgent".to_string(),
2495 source: SaveSource::QueryPanel,
2496 }),
2497 &tx,
2498 )
2499 .await;
2500
2501 assert_eq!(
2502 screen.panels.query().saved_search_breadcrumb().as_deref(),
2503 Some("urgent-todos"),
2504 "a persisted panel-sourced save re-pins the breadcrumb"
2505 );
2506 }
2507
2508 #[tokio::test]
2509 async fn persisted_note_browser_save_does_not_repin_even_on_equal_query() {
2510 let (mut screen, _, _, _dir) = test_screen().await;
2511
2512 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2513 screen.apply_saved_search("#todo".to_string(), "todo".to_string(), &tx);
2514
2515 screen
2518 .handle_app_message(
2519 AppEvent::SavedSearch(SavedSearchFlow::Persisted {
2520 name: "inbox".to_string(),
2521 query: "#todo".to_string(),
2522 source: SaveSource::NoteBrowser,
2523 }),
2524 &tx,
2525 )
2526 .await;
2527
2528 assert_eq!(
2529 screen.panels.query().saved_search_breadcrumb().as_deref(),
2530 Some("todo"),
2531 "a note-browser save must not steal the panel's provenance"
2532 );
2533 }
2534
2535 #[tokio::test]
2536 async fn persisted_query_as_name_save_skips_repin() {
2537 let (mut screen, _, _, _dir) = test_screen().await;
2538
2539 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2540 screen.apply_saved_search("#todo".to_string(), "todo".to_string(), &tx);
2541
2542 screen
2546 .handle_app_message(
2547 AppEvent::SavedSearch(SavedSearchFlow::Persisted {
2548 name: "#todo".to_string(),
2549 query: "#todo".to_string(),
2550 source: SaveSource::QueryPanel,
2551 }),
2552 &tx,
2553 )
2554 .await;
2555
2556 assert_eq!(
2557 screen.panels.query().saved_search_breadcrumb().as_deref(),
2558 Some("todo"),
2559 "a query-as-name save leaves the breadcrumb alone"
2560 );
2561 }
2562
2563 #[tokio::test]
2564 async fn save_search_confirmed_persists_then_emits_persisted() {
2565 let (mut screen, vault, _, _dir) = test_screen().await;
2566
2567 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2568 screen
2569 .handle_app_message(
2570 AppEvent::SavedSearch(SavedSearchFlow::Confirmed {
2571 name: "mine".to_string(),
2572 query: "#todo".to_string(),
2573 source: SaveSource::QueryPanel,
2574 }),
2575 &tx,
2576 )
2577 .await;
2578
2579 let event = tokio::time::timeout(std::time::Duration::from_secs(5), async {
2581 loop {
2582 match rx.recv().await {
2583 Some(e @ AppEvent::SavedSearch(SavedSearchFlow::Persisted { .. })) => break e,
2584 Some(_) => continue,
2585 None => panic!("channel closed before SavedSearchPersisted"),
2586 }
2587 }
2588 })
2589 .await
2590 .expect("SavedSearchPersisted within timeout");
2591
2592 let AppEvent::SavedSearch(SavedSearchFlow::Persisted {
2593 name,
2594 query,
2595 source,
2596 }) = event
2597 else {
2598 unreachable!()
2599 };
2600 assert_eq!((name.as_str(), query.as_str()), ("mine", "#todo"));
2601 assert_eq!(source, SaveSource::QueryPanel);
2602 let all = vault.list_saved_searches().await.unwrap();
2603 assert!(all.iter().any(|s| s.name == "mine" && s.query == "#todo"));
2604 }
2605
2606 #[tokio::test]
2607 async fn save_query_source_carries_panel_provenance() {
2608 let (mut screen, _, _, _dir) = test_screen().await;
2609
2610 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2611 screen.apply_saved_search("#todo".to_string(), "todo".to_string(), &tx);
2612
2613 assert_eq!(
2614 screen.save_query_source(),
2615 Some((
2616 "#todo".to_string(),
2617 Some("todo".to_string()),
2618 SaveSource::QueryPanel
2619 )),
2620 "the save dialog opens pre-filled with the breadcrumb provenance"
2621 );
2622 }
2623
2624 #[tokio::test]
2631 async fn saved_search_selected_then_close_overlay_keeps_backlinks_focus() {
2632 use crate::settings::AppSettings;
2633 use kimun_core::VaultConfig;
2634 use std::sync::RwLock;
2635
2636 let dir = tempfile::TempDir::new().unwrap();
2637 let vault = Arc::new(
2638 NoteVault::new(VaultConfig::new(crate::test_support::sys(dir.path())))
2639 .await
2640 .unwrap(),
2641 );
2642 let settings: SharedSettings = Arc::new(RwLock::new(AppSettings::default()));
2643 let mut screen = EditorScreen::new(vault, VaultPath::root(), settings);
2644
2645 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2646 screen
2648 .handle_app_message(
2649 AppEvent::SavedSearch(SavedSearchFlow::Selected {
2650 query: "<{note}".to_string(),
2651 name: "Backlinks (current note)".to_string(),
2652 }),
2653 &tx,
2654 )
2655 .await;
2656 screen.handle_app_message(AppEvent::CloseOverlay, &tx).await;
2657
2658 assert!(
2659 screen.panels.focused() == PanelKind::Drawer
2660 && screen.panels.active_drawer_view() == DrawerView::Find,
2661 "focus should remain on the FIND drawer after select + close"
2662 );
2663 assert!(!screen.overlays.is_open(), "overlay should be closed");
2664 }
2665
2666 #[tokio::test]
2671 async fn opener_action_does_not_replace_open_overlay() {
2672 use crate::settings::AppSettings;
2673 use kimun_core::VaultConfig;
2674 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2675 use std::sync::RwLock;
2676
2677 let dir = tempfile::TempDir::new().unwrap();
2678 let vault = Arc::new(
2679 NoteVault::new(VaultConfig::new(crate::test_support::sys(dir.path())))
2680 .await
2681 .unwrap(),
2682 );
2683 let settings: SharedSettings = Arc::new(RwLock::new(AppSettings::default()));
2684 let mut screen = EditorScreen::new(vault.clone(), VaultPath::root(), settings.clone());
2685
2686 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2687
2688 {
2691 let s = settings.read().unwrap();
2692 let modal = SavedSearchesModal::new(
2693 vault.clone(),
2694 s.key_bindings.clone(),
2695 s.icons(),
2696 tx.clone(),
2697 );
2698 drop(s);
2699 screen.overlays.open(Box::new(modal), screen.opener_focus());
2700 }
2701 assert_eq!(
2702 screen.overlays.active_kind(),
2703 Some(OverlayKind::SavedSearches),
2704 "precondition: SavedSearches overlay is active"
2705 );
2706
2707 let quick_note_event = KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL);
2711 {
2712 let s = settings.read().unwrap();
2713 let combo = key_event_to_combo(&quick_note_event).expect("Ctrl+W maps to a combo");
2714 assert_eq!(
2715 s.key_bindings.get_action(&combo),
2716 Some(ActionShortcuts::QuickNote),
2717 "test assumes Ctrl+W is bound to QuickNote"
2718 );
2719 }
2720
2721 screen.handle_input(&InputEvent::Key(quick_note_event), &tx);
2722
2723 assert_eq!(
2726 screen.overlays.active_kind(),
2727 Some(OverlayKind::SavedSearches),
2728 "open overlay must not be replaced by a QuickNote opener action"
2729 );
2730 }
2731
2732 #[tokio::test]
2736 async fn focus_action_is_noop_while_overlay_open() {
2737 use crate::settings::AppSettings;
2738 use kimun_core::VaultConfig;
2739 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2740 use std::sync::RwLock;
2741
2742 let dir = tempfile::TempDir::new().unwrap();
2743 let vault = Arc::new(
2744 NoteVault::new(VaultConfig::new(crate::test_support::sys(dir.path())))
2745 .await
2746 .unwrap(),
2747 );
2748 let settings: SharedSettings = Arc::new(RwLock::new(AppSettings::default()));
2749 let mut screen = EditorScreen::new(vault.clone(), VaultPath::root(), settings.clone());
2750
2751 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2752
2753 {
2755 let s = settings.read().unwrap();
2756 let modal = SavedSearchesModal::new(
2757 vault.clone(),
2758 s.key_bindings.clone(),
2759 s.icons(),
2760 tx.clone(),
2761 );
2762 drop(s);
2763 screen.overlays.open(Box::new(modal), screen.opener_focus());
2764 }
2765 assert_ne!(screen.panels.active_drawer_view(), DrawerView::Find);
2766 let focused_before = screen.panels.focused();
2767
2768 let focus_right = KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL);
2770 {
2771 let s = settings.read().unwrap();
2772 let combo = key_event_to_combo(&focus_right).expect("Ctrl+L maps to a combo");
2773 assert_eq!(
2774 s.key_bindings.get_action(&combo),
2775 Some(ActionShortcuts::FocusEditor),
2776 "test assumes Ctrl+L is bound to FocusEditor"
2777 );
2778 }
2779 screen.handle_input(&InputEvent::Key(focus_right), &tx);
2780
2781 assert_eq!(
2782 screen.panels.focused(),
2783 focused_before,
2784 "focus action must not move focus while an overlay is open"
2785 );
2786 assert_ne!(
2787 screen.panels.active_drawer_view(),
2788 DrawerView::Find,
2789 "focus action must not switch the drawer view while an overlay is open"
2790 );
2791 assert_eq!(
2792 screen.overlays.active_kind(),
2793 Some(OverlayKind::SavedSearches),
2794 "overlay stays active"
2795 );
2796 }
2797
2798 #[tokio::test(flavor = "multi_thread")]
2803 async fn open_journal_dismisses_open_overlay() {
2804 let vault = crate::test_support::temp_vault("editor-journal").await;
2805 vault.validate_and_init().await.unwrap();
2806 let settings = std::sync::Arc::new(std::sync::RwLock::new(
2807 crate::settings::AppSettings::default(),
2808 ));
2809 let mut screen = EditorScreen::new(vault.clone(), VaultPath::root(), settings.clone());
2810 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2811
2812 {
2813 let s = settings.read().unwrap();
2814 let modal = SavedSearchesModal::new(
2815 vault.clone(),
2816 s.key_bindings.clone(),
2817 s.icons(),
2818 tx.clone(),
2819 );
2820 drop(s);
2821 screen.present_overlay(Box::new(modal));
2822 }
2823 assert!(screen.overlays.is_open(), "precondition: overlay open");
2824
2825 let (details, _, _) = vault.journal_entry().await.unwrap();
2826 screen.try_open_path(details.path, None, &tx).await;
2827
2828 assert!(
2829 !screen.overlays.is_open(),
2830 "opening the journal must dismiss the overlay before loading the note"
2831 );
2832 }
2833
2834 #[tokio::test(flavor = "multi_thread")]
2838 async fn opening_attachment_shows_attachment_view() {
2839 let vault = crate::test_support::temp_vault("editor-attachment").await;
2840 vault.validate_and_init().await.unwrap();
2841 vault
2842 .save_attachment(&VaultPath::new("assets/diagram.png"), &[1, 2, 3])
2843 .await
2844 .unwrap();
2845 let settings = std::sync::Arc::new(std::sync::RwLock::new(
2846 crate::settings::AppSettings::default(),
2847 ));
2848 let mut screen = EditorScreen::new(vault.clone(), VaultPath::root(), settings);
2849 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2850
2851 assert!(screen.panels.editor().is_some());
2853
2854 screen
2855 .try_open_attachment(VaultPath::new("assets/diagram.png"), &tx)
2856 .await;
2857
2858 assert!(
2859 screen.panels.is_showing_attachment(),
2860 "the editor area shows the attachment view"
2861 );
2862 assert!(
2863 screen.panels.editor().is_none(),
2864 "no note editor is mounted while an attachment is shown"
2865 );
2866 assert_eq!(
2867 screen.panels.attachment_path(),
2868 Some(&VaultPath::new("assets/diagram.png"))
2869 );
2870
2871 vault
2873 .create_note(&VaultPath::new("note.md"), "hi")
2874 .await
2875 .unwrap();
2876 screen.open_path(VaultPath::new("note.md"), None, &tx).await;
2877 assert!(!screen.panels.is_showing_attachment());
2878 assert!(screen.panels.editor().is_some());
2879 }
2880
2881 #[tokio::test]
2882 async fn save_query_from_note_browser_opens_save_dialog() {
2883 use crate::settings::AppSettings;
2884 use kimun_core::VaultConfig;
2885 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2886 use std::sync::RwLock;
2887
2888 let dir = tempfile::TempDir::new().unwrap();
2889 let vault = Arc::new(
2890 NoteVault::new(VaultConfig::new(crate::test_support::sys(dir.path())))
2891 .await
2892 .unwrap(),
2893 );
2894 let settings: SharedSettings = Arc::new(RwLock::new(AppSettings::default()));
2895 let mut screen = EditorScreen::new(vault.clone(), VaultPath::root(), settings.clone());
2896
2897 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2898
2899 {
2901 let s = settings.read().unwrap();
2902 let provider = resolving_search_source(vault.clone(), s.current_last_paths(), None);
2903 let modal = NoteBrowserModal::with_initial_query(
2904 "Note Browser",
2905 BrowserScope::Query,
2906 provider,
2907 vault.clone(),
2908 s.key_bindings.clone(),
2909 s.icons(),
2910 tx.clone(),
2911 "#todo",
2912 );
2913 drop(s);
2914 screen.overlays.open(Box::new(modal), screen.opener_focus());
2915 }
2916 assert_eq!(
2917 screen.overlays.active_kind(),
2918 Some(OverlayKind::NoteBrowser),
2919 "precondition: note browser is active with a query"
2920 );
2921 assert_eq!(screen.overlays.active_query(), Some("#todo"));
2922
2923 let save_event = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL);
2926 {
2927 let s = settings.read().unwrap();
2928 let combo = key_event_to_combo(&save_event).expect("Ctrl+D maps to a combo");
2929 assert_eq!(
2930 s.key_bindings.get_action(&combo),
2931 Some(ActionShortcuts::SaveCurrentQuery),
2932 "test assumes Ctrl+D is bound to SaveCurrentQuery"
2933 );
2934 }
2935
2936 screen.handle_input(&InputEvent::Key(save_event), &tx);
2937
2938 assert_eq!(
2939 screen.overlays.active_kind(),
2940 Some(OverlayKind::Dialog),
2941 "saving from the note browser opens the save-search dialog"
2942 );
2943 }
2944
2945 #[tokio::test(flavor = "multi_thread")]
2948 async fn renaming_open_note_retargets_in_place() {
2949 let vault = crate::test_support::temp_vault("editor-rename").await;
2950 vault.validate_and_init().await.unwrap();
2951 let from = VaultPath::note_path_from("old");
2952 vault.create_note(&from, "# Old\n\nbody").await.unwrap();
2953 let settings = std::sync::Arc::new(std::sync::RwLock::new(
2954 crate::settings::AppSettings::default(),
2955 ));
2956 let mut screen = EditorScreen::new(vault.clone(), from.clone(), settings);
2957 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2958 screen.on_enter(&tx).await;
2959
2960 let to = VaultPath::note_path_from("new");
2961 vault.rename_note(&from, &to).await.unwrap();
2962 screen
2963 .handle_app_message(
2964 AppEvent::FileOp(FileOp::Renamed {
2965 from: from.clone(),
2966 to: to.clone(),
2967 }),
2968 &tx,
2969 )
2970 .await;
2971
2972 assert_eq!(screen.path, to, "editor retargets to the new path");
2973 assert!(
2974 !screen.panels.editor().unwrap().is_dirty(),
2975 "reloaded buffer is clean (won't clobber the renamed file)"
2976 );
2977 }
2978
2979 #[tokio::test(flavor = "multi_thread")]
2982 async fn open_then_save_marks_and_retitles_sidebar_row() {
2983 let vault = crate::test_support::temp_vault("editor-marksave").await;
2984 vault.validate_and_init().await.unwrap();
2985 vault
2986 .create_note(&VaultPath::note_path_from("alpha"), "# Alpha\n\nbody")
2987 .await
2988 .unwrap();
2989 let settings = std::sync::Arc::new(std::sync::RwLock::new(
2990 crate::settings::AppSettings::default(),
2991 ));
2992 let path = VaultPath::note_path_from("alpha");
2993 let mut screen = EditorScreen::new(vault.clone(), path.clone(), settings);
2994 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2995 screen.on_enter(&tx).await;
2996 for _ in 0..50 {
2997 screen.panels.sidebar_mut().poll_for_test();
2998 if !screen.panels.sidebar().is_loading_for_test() {
2999 break;
3000 }
3001 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
3002 }
3003
3004 assert!(
3005 screen
3006 .panels
3007 .sidebar()
3008 .note_row_is_open_for_test("alpha.md"),
3009 "the open note's row is marked"
3010 );
3011
3012 screen
3013 .handle_app_message(
3014 AppEvent::AutosaveCompleted {
3015 path: path.clone(),
3016 saved_revision: None,
3017 title: Some("New First Line".to_string()),
3018 },
3019 &tx,
3020 )
3021 .await;
3022
3023 assert_eq!(
3024 screen.panels.sidebar().note_row_title_for_test("alpha.md"),
3025 Some("New First Line".to_string()),
3026 "the saved note's row title updated in place"
3027 );
3028 }
3029
3030 #[tokio::test]
3034 async fn ask_drawer_switch_preserves_thread() {
3035 let (mut screen, _, _, _dir) = test_screen().await;
3036 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3037
3038 screen.open_drawer_view(DrawerView::Ask, &tx);
3040 assert!(screen.panels.is_showing_ask());
3041 {
3042 let panel = screen.panels.ask_mut();
3043 panel.thread_mut().ask("first?".into());
3044 panel.thread_mut().ask("second?".into());
3045 }
3046 assert_eq!(screen.panels.ask().thread().turns().len(), 2);
3047
3048 screen.open_drawer_view(DrawerView::Files, &tx);
3051 assert!(!screen.panels.is_showing_ask());
3052 assert_eq!(screen.panels.ask().thread().turns().len(), 2);
3053
3054 screen.open_drawer_view(DrawerView::Ask, &tx);
3056 assert!(screen.panels.is_showing_ask());
3057 assert_eq!(
3058 screen.panels.ask().thread().turns().len(),
3059 2,
3060 "the conversation survives the round trip"
3061 );
3062 }
3063
3064 #[tokio::test]
3070 async fn ask_answer_ready_reaches_the_thread_via_the_owned_message_path() {
3071 let (mut screen, _, _, _dir) = test_screen().await;
3072 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3073
3074 screen.open_drawer_view(DrawerView::Ask, &tx);
3075 let id = screen.panels.ask_mut().thread_mut().ask("q?".into());
3076
3077 screen
3078 .handle_app_message(
3079 AppEvent::Ask(crate::components::events::AskData::AnswerReady {
3080 turn_id: id,
3081 result: Ok(("the answer".into(), vec![])),
3082 }),
3083 &tx,
3084 )
3085 .await;
3086
3087 let panel = screen.panels.ask_mut();
3088 let turn = panel.thread().selected().expect("a turn is selected");
3089 assert!(matches!(turn.status, crate::ask::TurnStatus::Done));
3090 assert_eq!(turn.answer, "the answer");
3091 }
3092
3093 #[tokio::test]
3098 async fn opening_a_note_over_ask_shows_the_note_and_keeps_the_thread() {
3099 let vault = crate::test_support::temp_vault("editor-ask-open").await;
3100 vault.validate_and_init().await.unwrap();
3101 vault
3102 .create_note(&VaultPath::note_path_from("alpha"), "# Alpha\n\nbody")
3103 .await
3104 .unwrap();
3105 let settings = std::sync::Arc::new(std::sync::RwLock::new(
3106 crate::settings::AppSettings::default(),
3107 ));
3108 let mut screen = EditorScreen::new(vault, VaultPath::root(), settings);
3109 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3110
3111 screen.open_drawer_view(DrawerView::Ask, &tx);
3112 screen
3113 .panels
3114 .ask_mut()
3115 .thread_mut()
3116 .ask("a question?".into());
3117 assert!(screen.panels.is_showing_ask());
3118
3119 screen
3121 .open_path(VaultPath::note_path_from("alpha"), None, &tx)
3122 .await;
3123 assert!(!screen.panels.is_showing_ask(), "the note replaced Ask");
3124 assert!(screen.panels.editor().is_some(), "the note editor is shown");
3125 assert_eq!(
3126 screen.panels.active_drawer_view(),
3127 DrawerView::Files,
3128 "the Sources drawer must not linger on a hidden conversation"
3129 );
3130
3131 screen.open_drawer_view(DrawerView::Ask, &tx);
3133 assert_eq!(
3134 screen.panels.ask().thread().turns().len(),
3135 1,
3136 "the thread survived opening the note"
3137 );
3138 }
3139
3140 #[tokio::test]
3146 async fn opening_a_note_over_ask_with_hidden_drawer_keeps_it_hidden() {
3147 let vault = crate::test_support::temp_vault("editor-ask-hidden-drawer").await;
3148 vault.validate_and_init().await.unwrap();
3149 vault
3150 .create_note(&VaultPath::note_path_from("alpha"), "# Alpha\n\nbody")
3151 .await
3152 .unwrap();
3153 let settings = std::sync::Arc::new(std::sync::RwLock::new(
3154 crate::settings::AppSettings::default(),
3155 ));
3156 let mut screen = EditorScreen::new(vault, VaultPath::root(), settings);
3157 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3158
3159 screen.open_drawer_view(DrawerView::Ask, &tx);
3161 screen.panels.hide(PanelKind::Drawer);
3162 assert!(!screen.panels.is_visible(PanelKind::Drawer));
3163 assert!(screen.panels.is_showing_ask());
3164
3165 screen
3168 .open_path(VaultPath::note_path_from("alpha"), None, &tx)
3169 .await;
3170 assert!(!screen.panels.is_showing_ask(), "the note replaced Ask");
3171 assert!(
3172 !screen.panels.is_visible(PanelKind::Drawer),
3173 "a hidden drawer must not be force-revealed"
3174 );
3175 assert_eq!(
3176 screen.panels.active_drawer_view(),
3177 DrawerView::Files,
3178 "the view switched off Ask to FILES"
3179 );
3180 }
3181
3182 #[tokio::test]
3190 async fn rail_click_reenters_ask_in_one_click_after_a_note_stashed_it() {
3191 let vault = crate::test_support::temp_vault("editor-ask-rail-reentry").await;
3192 vault.validate_and_init().await.unwrap();
3193 vault
3194 .create_note(&VaultPath::note_path_from("alpha"), "# Alpha\n\nbody")
3195 .await
3196 .unwrap();
3197 let settings = std::sync::Arc::new(std::sync::RwLock::new(
3198 crate::settings::AppSettings::default(),
3199 ));
3200 let mut screen = EditorScreen::new(vault, VaultPath::root(), settings);
3201 screen.rag_status = crate::rag::RagStatus::Online {
3202 llm_available: true,
3203 };
3204 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3205
3206 screen
3207 .handle_app_message(AppEvent::OpenDrawerView(DrawerView::Ask), &tx)
3208 .await;
3209 assert!(screen.panels.is_showing_ask());
3210 screen
3211 .panels
3212 .ask_mut()
3213 .thread_mut()
3214 .ask("a question?".into());
3215
3216 screen
3217 .open_path(VaultPath::note_path_from("alpha"), None, &tx)
3218 .await;
3219 assert!(!screen.panels.is_showing_ask());
3220
3221 screen
3223 .handle_app_message(AppEvent::OpenDrawerView(DrawerView::Ask), &tx)
3224 .await;
3225 assert!(
3226 screen.panels.is_showing_ask(),
3227 "a single reopen must not read as a toggle-off"
3228 );
3229 assert_eq!(
3230 screen.panels.ask().thread().turns().len(),
3231 1,
3232 "the thread survived the round trip"
3233 );
3234 }
3235
3236 #[tokio::test]
3242 async fn opening_an_attachment_over_ask_shows_it_and_keeps_the_thread() {
3243 let vault = crate::test_support::temp_vault("editor-ask-open-att").await;
3244 vault.validate_and_init().await.unwrap();
3245 let att_path = VaultPath::new("pic.png");
3246 vault
3247 .save_attachment(&att_path, b"\x89PNG\r\n\x1a\n")
3248 .await
3249 .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, VaultPath::root(), settings);
3254 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3255
3256 screen.open_drawer_view(DrawerView::Ask, &tx);
3257 screen
3258 .panels
3259 .ask_mut()
3260 .thread_mut()
3261 .ask("a question?".into());
3262 assert!(screen.panels.is_showing_ask());
3263
3264 screen.open_attachment(att_path, &tx).await;
3266 assert!(
3267 !screen.panels.is_showing_ask(),
3268 "the attachment replaced Ask"
3269 );
3270
3271 screen.open_drawer_view(DrawerView::Ask, &tx);
3273 assert_eq!(
3274 screen.panels.ask().thread().turns().len(),
3275 1,
3276 "the thread survived opening the attachment"
3277 );
3278 }
3279
3280 #[tokio::test]
3284 async fn refresh_ask_capability_disables_without_a_client() {
3285 let (mut screen, _, _, _dir) = test_screen().await;
3286
3287 screen
3289 .ask
3290 .refresh_capability(&mut screen.panels, screen.rag_status)
3291 .await;
3292 assert!(
3293 !screen.panels.ask().has_client(),
3294 "the resident Ask panel loses its client without an LLM server"
3295 );
3296 }
3297
3298 #[tokio::test]
3301 async fn answer_lands_on_the_thread_while_browsing_files() {
3302 let (mut screen, _, _, _dir) = test_screen().await;
3303 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3304
3305 screen.open_drawer_view(DrawerView::Ask, &tx);
3306 let turn_id = screen.panels.ask_mut().thread_mut().ask("q?".into());
3307 screen.open_drawer_view(DrawerView::Files, &tx);
3309 assert!(!screen.panels.is_showing_ask());
3310
3311 screen.ask.handle_data(
3312 &mut screen.panels,
3313 crate::components::events::AskData::AnswerReady {
3314 turn_id,
3315 result: Ok(("the answer".into(), vec![])),
3316 },
3317 &tx,
3318 );
3319
3320 let thread = screen.panels.ask().thread();
3321 let turn = thread.turns().iter().find(|t| t.id == turn_id).unwrap();
3322 assert!(
3323 matches!(turn.status, crate::ask::TurnStatus::Done),
3324 "the answer completed the turn"
3325 );
3326 }
3327
3328 #[tokio::test]
3333 async fn sem_rail_entry_tracks_search_availability() {
3334 let (mut screen, _, _, _dir) = test_screen().await;
3335 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3336
3337 assert!(!screen.panels.rail_shows(DrawerView::Semantic));
3339 assert!(!screen.panels.rail_shows(DrawerView::Ask));
3340
3341 screen
3343 .handle_owned_message(
3344 AppEvent::RagStatus(crate::rag::RagStatus::Online {
3345 llm_available: false,
3346 }),
3347 &tx,
3348 )
3349 .await;
3350 assert!(screen.panels.rail_shows(DrawerView::Semantic));
3351 assert!(!screen.panels.rail_shows(DrawerView::Ask));
3352
3353 screen
3355 .handle_owned_message(AppEvent::RagStatus(crate::rag::RagStatus::Offline), &tx)
3356 .await;
3357 assert!(!screen.panels.rail_shows(DrawerView::Semantic));
3358
3359 screen
3361 .handle_owned_message(
3362 AppEvent::RagStatus(crate::rag::RagStatus::Online {
3363 llm_available: true,
3364 }),
3365 &tx,
3366 )
3367 .await;
3368 assert!(screen.panels.rail_shows(DrawerView::Semantic));
3369 assert!(screen.panels.rail_shows(DrawerView::Ask));
3370 }
3371
3372 #[tokio::test]
3378 async fn ask_focus_skips_the_llm_gate_when_already_showing_ask() {
3379 let (mut screen, _, _, _dir) = test_screen().await;
3380 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3381
3382 screen.rag_status = crate::rag::RagStatus::Online {
3383 llm_available: true,
3384 };
3385 screen.open_ask_workspace(&tx);
3386 assert!(screen.panels.is_showing_ask());
3387
3388 screen.rag_status = crate::rag::RagStatus::Offline;
3391
3392 screen.open_ask_workspace(&tx);
3393 assert!(screen.panels.is_showing_ask(), "still showing Ask");
3394 assert_eq!(screen.panels.focused(), PanelKind::Editor);
3395 assert!(
3396 rx.try_recv().is_err(),
3397 "no gate flash while Ask is already showing"
3398 );
3399 }
3400
3401 fn lay_out(screen: &mut EditorScreen) {
3413 let mut term = ratatui::Terminal::new(ratatui::backend::TestBackend::new(120, 40)).unwrap();
3414 term.draw(|f| screen.render(f)).unwrap();
3415 }
3416
3417 fn buffer_origin(screen: &EditorScreen) -> (u16, u16) {
3420 (0..40u16)
3421 .flat_map(|row| (0..120u16).map(move |col| (col, row)))
3422 .find(|(col, row)| screen.panels.is_note_buffer_cell(*col, *row))
3423 .expect("a laid-out screen always shows the note buffer")
3424 }
3425
3426 fn frame_corner(screen: &mut EditorScreen, tx: &AppTx) -> (u16, u16) {
3437 let (col, row) = buffer_origin(screen);
3438 let corner = (col - 1, row - 1);
3439 screen.panels.focus(PanelKind::Drawer);
3440 screen.handle_input(&press_at(corner.0, corner.1), tx);
3441 assert_eq!(
3442 screen.panels.focused(),
3443 PanelKind::Editor,
3444 "precondition: the frame is inside the editor column"
3445 );
3446 screen.clicks.end();
3448 corner
3449 }
3450
3451 fn press_at(col: u16, row: u16) -> InputEvent {
3452 use ratatui::crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
3453 InputEvent::Mouse(MouseEvent {
3454 kind: MouseEventKind::Down(MouseButton::Left),
3455 column: col,
3456 row,
3457 modifiers: KeyModifiers::NONE,
3458 })
3459 }
3460
3461 fn moved_at(col: u16, row: u16) -> InputEvent {
3462 use ratatui::crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
3463 InputEvent::Mouse(MouseEvent {
3464 kind: MouseEventKind::Moved,
3465 column: col,
3466 row,
3467 modifiers: KeyModifiers::NONE,
3468 })
3469 }
3470
3471 fn at(millis: u64) -> std::time::Instant {
3473 static ORIGIN: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
3474 *ORIGIN.get_or_init(std::time::Instant::now) + std::time::Duration::from_millis(millis)
3475 }
3476
3477 #[tokio::test]
3478 async fn two_presses_on_the_buffer_are_a_double_click() {
3479 let (mut screen, _, _, _dir) = test_screen().await;
3480 lay_out(&mut screen);
3481 let (col, row) = buffer_origin(&screen);
3482
3483 assert!(!screen.track_click(&press_at(col, row), at(0)));
3484 assert!(screen.track_click(&press_at(col, row), at(120)));
3485 }
3486
3487 #[tokio::test]
3493 async fn a_press_on_the_editor_frame_is_never_half_of_a_double() {
3494 let (mut screen, _, _, _dir) = test_screen().await;
3495 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3496 lay_out(&mut screen);
3497 let (col, row) = frame_corner(&mut screen, &tx);
3498
3499 assert!(!screen.track_click(&press_at(col, row), at(0)));
3500 assert!(
3501 !screen.track_click(&press_at(col, row), at(120)),
3502 "two clicks on the frame are not a follow"
3503 );
3504 }
3505
3506 #[tokio::test]
3510 async fn a_frame_press_separates_two_buffer_presses() {
3511 let (mut screen, _, _, _dir) = test_screen().await;
3512 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3513 lay_out(&mut screen);
3514 let (frame_col, frame_row) = frame_corner(&mut screen, &tx);
3515 let (col, row) = buffer_origin(&screen);
3516
3517 screen.track_click(&press_at(col, row), at(0));
3518 screen.track_click(&press_at(frame_col, frame_row), at(40));
3519 assert!(!screen.track_click(&press_at(col, row), at(80)));
3520 }
3521
3522 #[tokio::test]
3526 async fn motion_over_the_frame_does_not_break_a_double() {
3527 let (mut screen, _, _, _dir) = test_screen().await;
3528 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3529 lay_out(&mut screen);
3530 let (frame_col, frame_row) = frame_corner(&mut screen, &tx);
3531 let (col, row) = buffer_origin(&screen);
3532
3533 screen.track_click(&press_at(col, row), at(0));
3534 assert!(!screen.track_click(&moved_at(frame_col, frame_row), at(40)));
3535 assert!(screen.track_click(&press_at(col, row), at(120)));
3536 }
3537
3538 #[tokio::test]
3539 async fn a_key_between_presses_ends_the_click_run() {
3540 let (mut screen, _, _, _dir) = test_screen().await;
3541 lay_out(&mut screen);
3542 let (col, row) = buffer_origin(&screen);
3543
3544 screen.track_click(&press_at(col, row), at(0));
3545 assert!(!screen.track_click(&chr('x'), at(40)));
3546 assert!(!screen.track_click(&press_at(col, row), at(80)));
3547 }
3548
3549 #[tokio::test]
3554 async fn an_overlay_ends_the_click_run() {
3555 let (mut screen, _, _, _dir) = test_screen().await;
3556 lay_out(&mut screen);
3557 let (col, row) = buffer_origin(&screen);
3558 let vault = screen.vault.clone();
3559
3560 screen.track_click(&press_at(col, row), at(0));
3561 screen.present_overlay(Box::new(ActiveDialog::create_note(
3562 VaultPath::note_path_from("x"),
3563 vault,
3564 None,
3565 )));
3566 assert!(
3567 !screen.track_click(&press_at(col, row), at(40)),
3568 "a press under an overlay is the overlay's"
3569 );
3570 screen.dismiss_overlay();
3571 assert!(
3572 !screen.track_click(&press_at(col, row), at(80)),
3573 "and neither press survives to pair with the next one"
3574 );
3575 }
3576
3577 #[tokio::test]
3581 async fn a_double_click_on_a_wikilink_follows_it() {
3582 let (mut screen, _, _, _dir) = test_screen().await;
3583 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3584 screen
3585 .panels
3586 .editor_mut()
3587 .unwrap()
3588 .set_text("[[target note]]".to_string());
3589 lay_out(&mut screen);
3590 let (col, row) = buffer_origin(&screen);
3593 let (col, row) = (col + 6, row);
3594
3595 screen.handle_input(&press_at(col, row), &tx);
3596 screen.handle_input(&press_at(col, row), &tx);
3597
3598 let followed = std::iter::from_fn(|| rx.try_recv().ok())
3599 .find_map(|ev| match ev {
3600 AppEvent::FollowLink(target) => Some(target),
3601 _ => None,
3602 })
3603 .expect("the double-click emitted no FollowLink");
3604 assert_eq!(followed, "target note");
3605 }
3606
3607 #[tokio::test]
3610 async fn a_single_press_on_a_wikilink_only_places_the_cursor() {
3611 let (mut screen, _, _, _dir) = test_screen().await;
3612 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3613 screen
3614 .panels
3615 .editor_mut()
3616 .unwrap()
3617 .set_text("[[target note]]".to_string());
3618 lay_out(&mut screen);
3619 let (col, row) = buffer_origin(&screen);
3620
3621 screen.handle_input(&press_at(col + 6, row), &tx);
3622
3623 assert!(
3624 !std::iter::from_fn(|| rx.try_recv().ok())
3625 .any(|ev| matches!(ev, AppEvent::FollowLink(_))),
3626 "one click is a cursor placement, not a navigation"
3627 );
3628 assert!(
3629 screen
3630 .panels
3631 .editor()
3632 .unwrap()
3633 .follow_target_at_cursor()
3634 .is_some(),
3635 "but it did land on the link, so the next press has something to follow"
3636 );
3637 }
3638}
3639
3640#[cfg(test)]
3641mod sort_routing_tests {
3642 use super::*;
3643 use crate::app_screen::AppScreen;
3644 use crate::components::events::SortTarget;
3645 use crate::components::file_list::{SortField, SortOrder};
3646
3647 async fn make_editor() -> (
3648 EditorScreen,
3649 AppTx,
3650 tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
3651 ) {
3652 let vault = crate::test_support::temp_vault("editor-sort").await;
3653 vault.validate_and_init().await.unwrap();
3654 let settings = std::sync::Arc::new(std::sync::RwLock::new(
3655 crate::settings::AppSettings::default(),
3656 ));
3657 let screen = EditorScreen::new(vault, VaultPath::root(), settings);
3658 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
3659 (screen, tx, rx)
3660 }
3661
3662 #[tokio::test(flavor = "multi_thread")]
3663 async fn sort_save_default_persists_to_settings() {
3664 let (mut screen, tx, _rx) = make_editor().await;
3665 screen
3666 .handle_app_message(
3667 AppEvent::SortChanged {
3668 target: SortTarget::Sidebar,
3669 field: SortField::Title,
3670 order: SortOrder::Descending,
3671 group_directories: true,
3672 persist: true,
3673 },
3674 &tx,
3675 )
3676 .await;
3677 let s = screen.settings.read().unwrap();
3678 assert_eq!(
3679 s.default_sort_field,
3680 crate::settings::SortFieldSetting::Title
3681 );
3682 assert_eq!(
3683 s.default_sort_order,
3684 crate::settings::SortOrderSetting::Descending
3685 );
3686 assert!(s.group_directories);
3687 }
3688
3689 #[tokio::test(flavor = "multi_thread")]
3690 async fn sort_save_default_journal_dir_writes_journal_settings() {
3691 let (mut screen, tx, _rx) = make_editor().await;
3692 let journal = screen.vault.journal_path().clone();
3694 screen.panels.sidebar_mut().navigate(journal, &tx);
3695
3696 screen
3697 .handle_app_message(
3698 AppEvent::SortChanged {
3699 target: SortTarget::Sidebar,
3700 field: SortField::Title,
3701 order: SortOrder::Ascending,
3702 group_directories: false,
3703 persist: true,
3704 },
3705 &tx,
3706 )
3707 .await;
3708
3709 let s = screen.settings.read().unwrap();
3710 assert_eq!(
3711 s.journal_sort_field,
3712 crate::settings::SortFieldSetting::Title
3713 );
3714 assert_eq!(
3715 s.journal_sort_order,
3716 crate::settings::SortOrderSetting::Ascending
3717 );
3718 assert_eq!(
3720 s.default_sort_field,
3721 crate::settings::SortFieldSetting::Name
3722 );
3723 }
3724
3725 #[tokio::test(flavor = "multi_thread")]
3728 async fn sort_changed_without_persist_leaves_settings() {
3729 let (mut screen, tx, _rx) = make_editor().await;
3730 screen
3731 .handle_app_message(
3732 AppEvent::SortChanged {
3733 target: SortTarget::Sidebar,
3734 field: SortField::Title,
3735 order: SortOrder::Descending,
3736 group_directories: true,
3737 persist: false,
3738 },
3739 &tx,
3740 )
3741 .await;
3742 let s = screen.settings.read().unwrap();
3743 assert_eq!(
3744 s.default_sort_field,
3745 crate::settings::SortFieldSetting::Name
3746 );
3747 assert_eq!(
3748 s.default_sort_order,
3749 crate::settings::SortOrderSetting::Ascending
3750 );
3751 assert!(!s.group_directories);
3752 }
3753}