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, Rect};
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, SortField};
32use crate::components::footer_bar::{FooterBar, FooterTarget};
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::sortable::{SortState, SortableList};
42use crate::components::text_editor::TextEditorComponent;
43use crate::keys::KeyBindings;
44use crate::keys::action_shortcuts::{ActionShortcuts, TextAction};
45use crate::keys::leader::{LeaderAction, LeaderEngine, LeaderOutcome};
46use crate::settings::SharedSettings;
47use crate::settings::icons::Icons;
48use crate::settings::themes::Theme;
49use crate::util::single_slot_task::SingleSlotTask;
50
51const SAVE_TIMEOUT: Duration = Duration::from_secs(5);
58
59pub struct EditorScreen {
60 vault: Arc<NoteVault>,
61 settings: SharedSettings,
62 icons: Icons,
63 theme: Theme,
64 panels: PanelSet,
68 path: VaultPath,
69 footer: FooterBar,
70 title_targets: Vec<(Rect, TitleTarget)>,
72 pointer: Option<ratatui::layout::Position>,
76 doc_meta: crate::app_screen::doc_meta::DocMeta,
79 update: Option<crate::update::UpdateStatus>,
82 rag_status: crate::rag::RagStatus,
85 server_update: Option<crate::server_client::sync::ServerUpdate>,
87 ask: AskCoordinator,
90 leader: LeaderEngine,
92 clicks: crate::app_screen::click_run::ClickRun,
96 app_tx: Option<AppTx>,
99 autosave: AutosaveTimer,
100 overlays: OverlayHost<PanelKind>,
103 autosave_task: SingleSlotTask<()>,
112}
113
114impl EditorScreen {
115 pub fn new(vault: Arc<NoteVault>, path: VaultPath, settings: SharedSettings) -> Self {
116 let s = settings.read().unwrap();
117 let kb = s.key_bindings.clone();
118 let theme = s.get_theme();
119 let footer = FooterBar::new();
120 let icons = s.icons();
121 let sidebar = SidebarComponent::from_settings(vault.clone(), &s);
122 let query_panel = QueryPanel::new(vault.clone(), kb.clone(), icons.clone());
123 let semantic = crate::components::semantic_search::SemanticPanel::new(
124 vault.clone(),
125 settings.clone(),
126 s.icons(),
127 s.yank_combos(),
128 );
129 let tags = TagsPanel::new(vault.clone(), s.icons(), s.yank_combos());
130 let links = LinksPanel::new(vault.clone(), s.icons(), s.yank_combos());
131 let outline = OutlinePanel::new(s.icons(), s.yank_combos());
132 let drawer = DrawerHost::new(
133 vault.clone(),
134 &kb,
135 sidebar,
136 query_panel,
137 semantic,
138 tags,
139 links,
140 outline,
141 );
142 let rail_kb = kb.clone();
143 let mut editor = TextEditorComponent::new(kb, &s);
144 editor.set_vault(vault.clone());
145 let leader_engine = LeaderEngine::with_tree(s.leader_tree());
146 drop(s);
147 let rail_icons = icons.clone();
148 let ask = AskCoordinator::new(settings.clone(), vault.clone());
149 Self {
150 settings,
151 icons,
152 theme,
153 panels: PanelSet::from_panels(
157 drawer,
158 editor,
159 rail_icons,
160 rail_kb,
161 crate::components::activity_rail::RailCaps {
162 semantic: false,
163 ask: false,
164 },
165 ),
166 doc_meta: crate::app_screen::doc_meta::DocMeta::new(vault.clone()),
167 update: None,
168 rag_status: crate::rag::RagStatus::Disabled,
169 server_update: None,
170 ask,
171 vault,
172 path,
173 footer,
174 title_targets: Vec::new(),
175 pointer: None,
176 leader: leader_engine,
177 clicks: Default::default(),
178 app_tx: None,
179 autosave: AutosaveTimer::new(),
180 overlays: OverlayHost::new(),
181 autosave_task: SingleSlotTask::empty(),
182 }
183 }
184}
185
186fn encode_rgba_to_png(width: u32, height: u32, rgba: &[u8]) -> std::io::Result<Vec<u8>> {
188 let mut buf = Vec::new();
189 {
190 let mut encoder = png::Encoder::new(&mut buf, width, height);
191 encoder.set_color(png::ColorType::Rgba);
192 encoder.set_depth(png::BitDepth::Eight);
193 let mut writer = encoder
194 .write_header()
195 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
196 writer
197 .write_image_data(rgba)
198 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
199 }
200 Ok(buf)
201}
202
203impl EditorScreen {
204 fn try_paste_image(&mut self, tx: &AppTx) -> bool {
213 let Some(editor) = self.panels.editor_mut() else {
214 return false;
215 };
216 let img = match editor.take_clipboard_image() {
217 Some(i) if !i.rgba.is_empty() && i.width > 0 && i.height > 0 => i,
218 _ => return false,
219 };
220 let expected = img
223 .width
224 .checked_mul(img.height)
225 .and_then(|n| n.checked_mul(4));
226 if expected != Some(img.rgba.len()) {
227 self.footer
228 .flash("Clipboard image size mismatch".to_string(), tx);
229 return true;
230 }
231 let asset_path = self.vault.generate_attachment_path("image", "png");
237 let link_path = asset_path.relative_link_from_note(&self.path);
238 let markdown = format!("");
239 let vault = self.vault.clone();
240 let tx2 = tx.clone();
241 let width = img.width as u32;
242 let height = img.height as u32;
243 let rgba = img.rgba;
244 tokio::spawn(async move {
245 let png_bytes =
247 match tokio::task::spawn_blocking(move || encode_rgba_to_png(width, height, &rgba))
248 .await
249 {
250 Ok(Ok(b)) => b,
251 Ok(Err(e)) => {
252 tx2.send(AppEvent::OverlayData(OverlayData::Error(format!(
253 "Image encode failed: {e}"
254 ))))
255 .ok();
256 return;
257 }
258 Err(e) => {
259 tx2.send(AppEvent::OverlayData(OverlayData::Error(format!(
260 "Image encode task failed: {e}"
261 ))))
262 .ok();
263 return;
264 }
265 };
266 match vault.save_attachment(&asset_path, &png_bytes).await {
267 Ok(()) => {
268 tx2.send(AppEvent::InsertAtCursor(markdown)).ok();
269 tx2.send(AppEvent::FlashMessage("image pasted".into())).ok();
273 }
274 Err(e) => {
275 tx2.send(AppEvent::OverlayData(OverlayData::Error(format!(
276 "Image save failed: {e}"
277 ))))
278 .ok();
279 }
280 }
281 });
282 true
283 }
284
285 #[cfg(test)]
288 async fn persist_saved_search(&self, name: &str, query: &str) -> Result<(), VaultError> {
289 self.vault.save_search(name, query).await
290 }
291
292 async fn follow_link(&mut self, target: String, tx: &AppTx) {
293 if kimun_core::note::scan::is_remote_url(&target) {
295 match open::that_detached(&target) {
296 Ok(()) => self.footer.flash(format!("Opening {target}"), tx),
297 Err(e) => self.footer.flash(format!("Cannot open URL: {e}"), tx),
298 }
299 return;
300 }
301
302 if kimun_core::note::scan::target_looks_like_image(&target) {
306 let parent = self.path.get_parent_path().0;
307 let resolved = parent.append(&VaultPath::new(target.trim()));
308 let os_path = self.vault.path_to_pathbuf(&resolved);
309 match open::that_detached(&os_path) {
310 Ok(()) => self.footer.flash(format!("Opening {target}"), tx),
311 Err(e) => self.footer.flash(format!("Cannot open image: {e}"), tx),
312 }
313 return;
314 }
315
316 let (target_clean, _) = kimun_core::note::scan::split_link_fragment(&target);
321 let path = kimun_core::nfs::VaultPath::note_path_from(target_clean)
325 .resolve_link_in_note(&self.path);
326 match self.vault.open_or_search(&path).await {
327 Ok(results) if results.is_empty() => {
328 self.present_overlay(Box::new(ActiveDialog::create_note(
329 path,
330 self.vault.clone(),
331 None,
332 )));
333 }
334 Ok(mut results) if results.len() == 1 => {
335 let (entry, _) = results.remove(0);
336 self.open_path(entry.path, None, tx).await;
337 }
338 Ok(results) => {
339 use crate::components::note_browser::link_results_provider::LinkResultsProvider;
340 let provider = LinkResultsProvider::from_results(results);
341 let s = self.settings.read().unwrap();
342 let modal = NoteBrowserModal::new(
343 format!("Follow: {target}"),
344 BrowserScope::Files,
345 provider,
346 self.vault.clone(),
347 s.key_bindings.clone(),
348 s.icons(),
349 tx.clone(),
350 );
351 drop(s);
352 self.present_overlay(Box::new(modal));
353 }
354 Err(e) => {
355 self.footer.flash(format!("Link error: {e}"), tx);
356 }
357 }
358 }
359
360 pub async fn open_path(&mut self, path: VaultPath, emphasis: Option<Vec<String>>, tx: &AppTx) {
361 if !path.is_note() {
362 if let Ok(kimun_core::EntryKind::Attachment) = self.vault.entry_kind(&path).await {
367 self.open_attachment(path, tx).await;
368 return;
369 }
370 tx.send(AppEvent::OpenScreen(ScreenEvent::OpenBrowse(
371 self.vault.clone(),
372 path,
373 )))
374 .ok();
375 return;
376 }
377
378 self.try_save().await;
380
381 self.path = path.clone();
382 self.hide_ask_if_shown();
387 self.panels.clear_attachment();
388 self.panels
390 .sidebar_mut()
391 .set_open_note(Some(self.path.clone()));
392 match self.vault.get_note_text(&self.path).await {
393 Ok(content) => {
394 self.settings.read().unwrap().add_path_history(&path);
398 let settings_snapshot = self.settings.read().unwrap().clone();
399 tokio::spawn(async move {
400 settings_snapshot.save_to_disk().ok();
401 });
402 self.doc_meta.note_opened(&self.path, tx);
403 if let Some(ed) = self.panels.editor_mut() {
404 ed.open_note(self.path.clone(), content);
405 if let Some(needles) = emphasis {
408 ed.set_search_needles(needles);
409 }
410 ed.set_redraw_tx(tx);
411 }
412 tx.send(AppEvent::Redraw).ok();
413 self.reflect_open_note_in_drawers(tx);
416 }
417 Err(e) => {
418 if matches!(e, VaultError::FSError(FSError::VaultPathNotFound { .. })) {
419 self.present_overlay(Box::new(ActiveDialog::create_note(
420 self.path.clone(),
421 self.vault.clone(),
422 None,
423 )));
424 } else {
425 tracing::error!("Failed to read note {}: {e}", self.path);
426 let parent = self.path.get_parent_path().0;
427 tx.send(AppEvent::OpenScreen(ScreenEvent::OpenBrowse(
428 self.vault.clone(),
429 parent,
430 )))
431 .ok();
432 }
433 return;
434 }
435 }
436
437 let note_parent = path.get_parent_path().0;
440 if self.panels.sidebar().is_empty() {
441 self.navigate_sidebar(note_parent, tx);
442 }
443
444 let interval = self.settings.read().unwrap().autosave_interval_secs;
446 self.autosave.restart(interval, tx.clone());
447 }
448
449 fn hide_ask_if_shown(&mut self) {
455 self.ask.hide_if_shown(&mut self.panels);
456 }
457
458 async fn open_attachment(&mut self, path: VaultPath, tx: &AppTx) {
464 self.try_save().await;
466 self.autosave_task.abort();
467
468 match self.vault.get_attachment_details(&path).await {
469 Ok(details) => {
470 let (icons, kb) = {
471 let s = self.settings.read().unwrap();
472 (s.icons(), s.key_bindings.clone())
473 };
474 let view = AttachmentView::new(details, icons, kb);
475 self.path = path;
476 self.hide_ask_if_shown();
479 self.panels.show_attachment(view);
480 self.panels.sidebar_mut().set_open_note(None);
481 tx.send(AppEvent::Redraw).ok();
482 }
483 Err(e) => {
484 self.footer
485 .flash(format!("Cannot open attachment: {e}"), tx);
486 }
487 }
488 }
489
490 fn navigate_sidebar(&mut self, dir: VaultPath, tx: &AppTx) {
491 self.panels.sidebar_mut().navigate(dir, tx);
495 }
496
497 fn refresh_sidebar_if_showing(&mut self, dir: &VaultPath, tx: &AppTx) {
503 self.panels.sidebar_mut().refresh_if_showing(dir, tx);
504 }
505
506 fn note_saved(&mut self, path: &VaultPath, raw_title: String) {
510 let title = FileListEntry::display_title(raw_title);
511 self.panels.sidebar_mut().update_note_row(path, &title);
512 }
513
514 async fn try_save(&mut self) {
515 if self.autosave_task.is_in_flight() {
532 match self.autosave_task.await_with_timeout(SAVE_TIMEOUT).await {
533 Some(_) => {} None => {
535 self.autosave_task.abort();
537 return;
538 }
539 }
540 }
541 let Some(text) = self
543 .panels
544 .editor()
545 .filter(|e| e.is_dirty())
546 .map(|e| e.get_text())
547 else {
548 return;
549 };
550 let save = self.vault.save_note(&self.path, &text);
554 if let Ok(Ok((_, content))) = tokio::time::timeout(SAVE_TIMEOUT, save).await {
555 if let Some(ed) = self.panels.editor_mut() {
556 ed.mark_saved(text);
557 }
558 let path = self.path.clone();
559 self.note_saved(&path, content.title);
560 }
561 }
562
563 fn spawn_autosave(&mut self, tx: &AppTx) {
572 if self.autosave_task.is_in_flight() {
575 return;
576 }
577 let Some(ed) = self.panels.editor() else {
578 return;
579 };
580 if !ed.is_dirty() {
581 return;
582 }
583 let text = ed.get_text();
584 let revision = ed.content_revision();
585 let vault = self.vault.clone();
586 let path = self.path.clone();
587 let tx = tx.clone();
588 self.autosave_task.spawn(async move {
589 let (saved_revision, title) = match vault.save_note(&path, &text).await {
590 Ok((_, content)) => (Some(revision), Some(content.title)),
591 Err(_) => (None, None),
592 };
593 let _ = tx.send(AppEvent::AutosaveCompleted {
594 path,
595 saved_revision,
596 title,
597 });
598 });
599 }
600
601 fn opener_focus(&self) -> PanelKind {
604 self.panels.focused()
605 }
606
607 fn present_overlay(&mut self, overlay: Box<dyn Overlay>) {
611 self.leader.cancel();
614 let opener = self.opener_focus();
615 self.overlays.open(overlay, opener);
616 }
617
618 fn dismiss_overlay(&mut self) {
626 if let Some(opener) = self.overlays.close() {
627 self.panels.focus(opener);
628 }
629 }
630
631 fn dismiss_all_overlays(&mut self) {
635 if let Some(opener) = self.overlays.close_all() {
636 self.panels.focus(opener);
637 }
638 }
639
640 async fn on_entry_op(&mut self, from: VaultPath, tx: &AppTx) {
641 self.dismiss_all_overlays();
642 if from.is_like(&self.path) {
647 self.autosave.stop();
648 self.try_save().await;
649 let parent = self.path.get_parent_path().0;
650 tx.send(AppEvent::OpenScreen(ScreenEvent::OpenBrowse(
651 self.vault.clone(),
652 parent,
653 )))
654 .ok();
655 } else {
656 self.refresh_sidebar_if_showing(&from.get_parent_path().0, tx);
657 }
658 }
659
660 async fn on_note_renamed(&mut self, from: VaultPath, to: VaultPath, tx: &AppTx) {
666 self.dismiss_all_overlays();
667 self.panels.sidebar_mut().rename_note_row(&from, &to);
668 if from.is_like(&self.path) {
672 self.autosave_task.abort();
678 match self.vault.get_note_text(&to).await {
679 Ok(text) => {
680 self.path = to.clone();
681 if let Some(ed) = self.panels.editor_mut() {
682 ed.renamed_to(to.clone());
683 ed.set_text(text.clone());
684 ed.mark_saved(text);
685 }
686 self.panels
687 .sidebar_mut()
688 .set_open_note(Some(self.path.clone()));
689 self.doc_meta.note_opened(&self.path, tx);
690 self.reflect_open_note_in_drawers(tx);
691 let interval = self.settings.read().unwrap().autosave_interval_secs;
693 self.autosave.restart(interval, tx.clone());
694 }
695 Err(_) => {
696 self.autosave.stop();
701 let parent = to.get_parent_path().0;
702 tx.send(AppEvent::OpenScreen(ScreenEvent::OpenBrowse(
703 self.vault.clone(),
704 parent,
705 )))
706 .ok();
707 }
708 }
709 }
710 }
711}
712
713#[derive(Debug, Clone, Copy, PartialEq, Eq)]
715enum TitleTarget {
716 Palette,
718 Workspace,
720}
721
722impl EditorScreen {
723 fn click_chrome(&mut self, col: u16, row: u16, tx: &AppTx) -> bool {
725 if let Some(target) = self.title_target_at(col, row) {
726 let open = match target {
727 TitleTarget::Palette => OverlayOpen::CommandPalette,
728 TitleTarget::Workspace => OverlayOpen::WorkspaceSwitcher,
729 };
730 self.open_overlay(open, tx);
731 return true;
732 }
733 let Some(target) = self.footer.target_at(col, row) else {
734 return false;
735 };
736 match target {
737 FooterTarget::Props => {
738 if let Some(path) = self.open_note().cloned() {
739 tx.send(AppEvent::FileOp(FileOp::ShowProperties(path))).ok();
740 }
741 }
742 FooterTarget::Update => {
743 tx.send(AppEvent::Update(UpdateFlow::ShowDialog)).ok();
744 }
745 FooterTarget::ServerUpdate => {
746 if let Some(update) = self.server_update.clone() {
747 self.present_overlay(Box::new(ActiveDialog::server_update(&update)));
748 }
749 }
750 FooterTarget::Backlinks => {
751 self.execute_leader_action(
752 LeaderAction::LinksTab(crate::components::drawer_views::LinksTab::Backlinks),
753 tx,
754 );
755 }
756 FooterTarget::Link => {
757 self.follow_link_at_cursor(tx);
758 }
759 }
760 true
761 }
762
763 fn title_target_at(&self, col: u16, row: u16) -> Option<TitleTarget> {
765 crate::components::clickable::target_at(&self.title_targets, col, row)
766 }
767
768 fn input_ctx(&self, double_click: bool) -> InputCtx {
775 InputCtx {
776 overlay: self.overlays.active_kind(),
777 leader_pending: self.leader.is_pending(),
778 focused: self.panels.focused(),
779 drawer_view: self.panels.active_drawer_view(),
780 space_leads: self.panels.editor().is_some_and(|e| e.space_leads()),
781 claim: self.panels.editor().map(|e| e.claim()).unwrap_or_default(),
782 double_click,
783 overlay_sortable: self.overlays.active_sortable().is_some(),
784 overlay_query: self.overlays.active_takes_query_syntax(),
785 pointer_on_editor: false,
786 }
787 }
788
789 fn input_ctx_for(&self, event: &InputEvent, double_click: bool) -> InputCtx {
793 InputCtx {
794 pointer_on_editor: match event {
795 InputEvent::Mouse(m) => self.panels.pointer_on_editor(m.column, m.row),
796 _ => false,
797 },
798 ..self.input_ctx(double_click)
799 }
800 }
801
802 fn track_click(&mut self, event: &InputEvent, now: std::time::Instant) -> bool {
826 let InputEvent::Mouse(mouse) = event else {
827 self.clicks.end();
828 return false;
829 };
830 if self.overlays.is_open() {
831 self.clicks.end();
832 return false;
833 }
834 let on_buffer = matches!(
839 mouse.kind,
840 ratatui::crossterm::event::MouseEventKind::Down(_)
841 ) && self.panels.is_note_buffer_cell(mouse.column, mouse.row);
842 self.clicks.observe(mouse, on_buffer, now)
843 }
844
845 fn apply_classification(
849 &mut self,
850 classification: Classification,
851 event: &InputEvent,
852 tx: &AppTx,
853 ) -> EventState {
854 if let Some(chord) = classification.flash {
855 self.footer.flash(chord, tx);
856 }
857 if classification.cancel_leader {
858 self.leader.cancel();
859 }
860 self.execute_intent(classification.intent, event, tx)
861 }
862
863 fn execute_intent(
864 &mut self,
865 intent: EditorIntent,
866 event: &InputEvent,
867 tx: &AppTx,
868 ) -> EventState {
869 match intent {
870 EditorIntent::Consume => EventState::Consumed,
871 EditorIntent::EditorPaste => {
872 if !self.try_paste_image(tx)
873 && let InputEvent::Paste(text) = event
874 && !text.is_empty()
875 && let Some(ed) = self.panels.editor_mut()
876 {
877 ed.paste_text(text, tx);
878 }
879 EventState::Consumed
880 }
881 EditorIntent::ImageProbe => {
882 if self.try_paste_image(tx) {
883 EventState::Consumed
884 } else {
885 let ctx = self.input_ctx_for(event, false);
891 let classification = {
892 let s = self.settings.read().unwrap();
893 crate::app_screen::editor_input::classify_tail(
894 event,
895 &s.key_bindings,
896 &ctx,
897 false,
898 )
899 };
900 self.apply_classification(classification, event, tx)
901 }
902 }
903 EditorIntent::FollowLink => {
904 if self.follow_link_at_cursor(tx) {
905 EventState::Consumed
906 } else if matches!(event, InputEvent::Mouse(_)) {
907 self.execute_intent(EditorIntent::Mouse, event, tx)
915 } else {
916 EventState::Consumed
917 }
918 }
919 EditorIntent::LeaderKey(key) => self.handle_leader_key(&key, tx),
920 EditorIntent::LeaderStart => {
921 self.leader.start();
922 self.schedule_whichkey_reveal(tx);
923 EventState::Consumed
924 }
925 EditorIntent::Op(op) => {
926 self.run_op(op, tx);
927 EventState::Consumed
928 }
929 EditorIntent::ToggleOverlay { kind, open } => {
930 if self.overlays.active_kind() == Some(kind) {
931 self.dismiss_overlay();
932 } else {
933 self.open_overlay(open, tx);
934 }
935 EventState::Consumed
936 }
937 EditorIntent::OpenOverlay(open) => {
938 self.open_overlay(open, tx);
939 EventState::Consumed
940 }
941 EditorIntent::Overlay => self.overlays.handle_input(event, tx),
942 EditorIntent::Mouse => {
943 if let InputEvent::Mouse(m) = event
946 && crate::components::clickable::is_left_press(m)
947 && self.click_chrome(m.column, m.row, tx)
948 {
949 return EventState::Consumed;
950 }
951 let state = self.panels.handle_mouse(event, tx);
955 self.ask.sync_sources(&mut self.panels, tx);
957 if self.panels.editor().is_some_and(|e| e.wants_context_menu) {
961 if let Some(ed) = self.panels.editor_mut() {
962 ed.wants_context_menu = false;
963 }
964 tx.send(AppEvent::FileOp(FileOp::ShowMenu(self.path.clone())))
965 .ok();
966 }
967 state
968 }
969 EditorIntent::Panel { fallback } => {
970 let state = self.panels.handle_input(event, tx);
971 self.ask.sync_sources(&mut self.panels, tx);
974 if state == EventState::NotConsumed {
975 match fallback {
976 PanelFallback::None => state,
977 PanelFallback::FocusCycle(CycleDir::Right) => {
978 self.focus_right(tx);
979 EventState::Consumed
980 }
981 PanelFallback::FocusCycle(CycleDir::Left) => {
982 self.focus_left(tx);
983 EventState::Consumed
984 }
985 PanelFallback::FocusEditor => {
986 self.focus_editor();
987 EventState::Consumed
988 }
989 }
990 } else {
991 state
992 }
993 }
994 }
995 }
996
997 fn run_op(&mut self, op: EditorOp, tx: &AppTx) {
998 match op {
999 EditorOp::ToggleDrawer => self.toggle_drawer(tx),
1000 EditorOp::FocusLeft => self.focus_left(tx),
1001 EditorOp::FocusRight => self.focus_right(tx),
1002 EditorOp::OpenJournal => {
1003 tx.send(AppEvent::OpenJournal).ok();
1004 }
1005 EditorOp::ShowFileOps => {
1006 tx.send(AppEvent::FileOp(FileOp::ShowMenu(self.path.clone())))
1007 .ok();
1008 }
1009 EditorOp::ToggleQueryPanel => self.toggle_backlinks(tx),
1010 EditorOp::OpenFileBrowserReveal => {
1011 self.open_drawer_view(DrawerView::Files, tx);
1012 self.reveal_note_dir_in_sidebar(tx);
1013 }
1014 EditorOp::SaveCurrentQuery => {
1015 if let Some((query, provenance, source)) = self.save_query_source() {
1016 self.present_overlay(Box::new(ActiveDialog::save_search(
1020 query,
1021 provenance,
1022 source,
1023 self.vault.clone(),
1024 tx,
1025 )));
1026 }
1027 }
1028 EditorOp::FindInBuffer => {
1029 if let Some(ed) = self.panels.editor_mut() {
1030 ed.open_or_advance_search();
1031 }
1032 }
1033 EditorOp::ReplaceInBuffer => {
1034 if let Some(ed) = self.panels.editor_mut() {
1035 ed.open_replace();
1036 }
1037 }
1038 EditorOp::ApplyText(text_action) => {
1039 if let Some(ed) = self.panels.editor_mut() {
1040 ed.apply_text_action(text_action);
1041 }
1042 }
1043 EditorOp::OpenAsk => self.open_ask_workspace(tx),
1044 }
1045 }
1046
1047 fn open_overlay(&mut self, open: OverlayOpen, tx: &AppTx) {
1057 let stacks = matches!(
1058 open,
1059 OverlayOpen::SortBrowser | OverlayOpen::QueryHelpOverBrowser
1060 );
1061 if self.overlays.is_open() != stacks {
1062 return;
1063 }
1064 let Some(overlay) = self.build_overlay(open, tx) else {
1065 return;
1066 };
1067 if stacks {
1068 self.leader.cancel();
1069 self.overlays.open_over(overlay);
1070 } else {
1071 self.present_overlay(overlay);
1072 }
1073 }
1074
1075 fn sortable(&self, target: SortTarget) -> Option<&dyn SortableList> {
1078 match target {
1079 SortTarget::Sidebar => Some(self.panels.sidebar()),
1080 SortTarget::Query => Some(self.panels.query()),
1081 SortTarget::Browser => self.overlays.active_sortable(),
1082 }
1083 }
1084
1085 fn sortable_mut(&mut self, target: SortTarget) -> Option<&mut dyn SortableList> {
1088 match target {
1089 SortTarget::Sidebar => Some(self.panels.sidebar_mut()),
1090 SortTarget::Query => Some(self.panels.query_mut()),
1091 SortTarget::Browser => self.overlays.parked_sortable_mut(),
1092 }
1093 }
1094
1095 fn sort_dialog(&self, target: SortTarget, tx: &AppTx) -> Option<Box<dyn Overlay>> {
1099 let list = self.sortable(target)?;
1100 let allows_property = list.allows_property();
1101 Some(Box::new(ActiveDialog::sort(
1102 target,
1103 list.sort_state(),
1104 list.is_unsorted(),
1105 allows_property,
1106 allows_property.then(|| self.vault.clone()),
1107 tx,
1108 )))
1109 }
1110
1111 fn open_note(&self) -> Option<&VaultPath> {
1119 if self.panels.is_showing_ask() || !self.path.is_note() {
1120 None
1121 } else {
1122 Some(&self.path)
1123 }
1124 }
1125
1126 fn open_note_or_flash(&self, tx: &AppTx) -> Option<VaultPath> {
1128 let note = self.open_note().cloned();
1129 if note.is_none() {
1130 tx.send(AppEvent::FlashMessage("no note open".into())).ok();
1131 }
1132 note
1133 }
1134
1135 fn toggle_pin_open_note(&self, tx: &AppTx) {
1140 let Some(path) = self.open_note_or_flash(tx) else {
1141 return;
1142 };
1143 let vault = self.vault.clone();
1144 let tx = tx.clone();
1145 tokio::spawn(async move {
1146 let msg = match vault.toggle_pinned_note(&path).await {
1147 Ok(kimun_core::PinToggle::Pinned { position }) => format!(
1148 "pinned as {} of {}",
1149 position + 1,
1150 kimun_core::PINNED_NOTES_CAP
1151 ),
1152 Ok(kimun_core::PinToggle::Unpinned) => "unpinned".to_string(),
1153 Ok(kimun_core::PinToggle::Full) => format!(
1154 "already {} pinned — unpin one first",
1155 kimun_core::PINNED_NOTES_CAP
1156 ),
1157 Err(VaultError::FSError(FSError::VaultPathNotFound { .. })) => {
1158 format!("note not found: {path}")
1159 }
1160 Err(e) => {
1161 tracing::warn!("failed to toggle pin on {path}: {e}");
1162 format!("could not update pinned notes: {e}")
1163 }
1164 };
1165 tx.send(AppEvent::FlashMessage(msg)).ok();
1166 });
1167 }
1168
1169 fn jump_to_pinned(&self, n: u8, tx: &AppTx) {
1172 let vault = self.vault.clone();
1173 let tx = tx.clone();
1174 tokio::spawn(async move {
1175 let pins = match vault.list_pinned_notes().await {
1176 Ok(pins) => pins,
1177 Err(e) => {
1178 tx.send(AppEvent::FlashMessage(format!(
1179 "could not read pinned notes: {e}"
1180 )))
1181 .ok();
1182 return;
1183 }
1184 };
1185 let Some(path) = pins.get(usize::from(n).saturating_sub(1)).cloned() else {
1186 tx.send(AppEvent::FlashMessage(format!("no pinned note {n}")))
1187 .ok();
1188 return;
1189 };
1190 if !vault.exists(&path).await {
1191 tx.send(AppEvent::FlashMessage(format!(
1192 "pinned note not found: {path}"
1193 )))
1194 .ok();
1195 return;
1196 }
1197 tx.send(AppEvent::OpenPath {
1198 path,
1199 emphasis: None,
1200 })
1201 .ok();
1202 });
1203 }
1204
1205 fn apply_sort(&mut self, target: SortTarget, state: &SortState, tx: &AppTx) {
1209 if let Some(list) = self.sortable_mut(target) {
1210 list.apply_sort(state, tx);
1211 }
1212 }
1213
1214 fn persist_sidebar_sort(&mut self, state: &SortState) {
1219 let is_journal = self.panels.sidebar().is_current_journal();
1220 self.panels
1221 .sidebar_mut()
1222 .remember_default(state.field.clone(), state.order);
1223 {
1224 let mut s = self.settings.write().unwrap();
1225 let field = crate::settings::SortFieldSetting::from(state.field.clone());
1226 let order = crate::settings::SortOrderSetting::from(state.order);
1227 if is_journal {
1228 s.journal_sort_field = field;
1229 s.journal_sort_order = order;
1230 } else {
1231 s.default_sort_field = field;
1232 s.default_sort_order = order;
1233 }
1234 if let Some(group) = state.group_dirs {
1235 s.group_directories = group;
1236 }
1237 }
1238 let snapshot = self.settings.read().unwrap().clone();
1239 tokio::spawn(async move {
1240 snapshot.save_to_disk().ok();
1241 });
1242 }
1243
1244 fn build_overlay(&self, open: OverlayOpen, tx: &AppTx) -> Option<Box<dyn Overlay>> {
1249 let s = self.settings.read().unwrap();
1250 let overlay: Box<dyn Overlay> = match open {
1251 OverlayOpen::SearchBrowser => {
1254 let provider = resolving_search_source(
1255 self.vault.clone(),
1256 s.current_last_paths(),
1257 Some(self.path.clone()),
1258 );
1259 Box::new(NoteBrowserModal::new(
1260 "Note Browser",
1261 BrowserScope::Query,
1262 provider,
1263 self.vault.clone(),
1264 s.key_bindings.clone(),
1265 s.icons(),
1266 tx.clone(),
1267 ))
1268 }
1269 OverlayOpen::FileFinder => {
1272 let current_dir = self.path.get_parent_path().0;
1273 let provider = FileFinderProvider::new(self.vault.clone(), current_dir);
1274 Box::new(
1275 NoteBrowserModal::new(
1276 "Find Note",
1277 BrowserScope::Files,
1278 provider,
1279 self.vault.clone(),
1280 s.key_bindings.clone(),
1281 s.icons(),
1282 tx.clone(),
1283 )
1284 .with_row_sort(),
1285 )
1286 }
1287 OverlayOpen::SavedSearches => Box::new(SavedSearchesModal::new(
1289 self.vault.clone(),
1290 s.key_bindings.clone(),
1291 s.icons(),
1292 tx.clone(),
1293 )),
1294 OverlayOpen::CommandPalette => {
1296 let gateway = s
1297 .key_bindings
1298 .first_combo_for(&ActionShortcuts::Leader)
1299 .unwrap_or_else(|| "leader".to_string());
1300 Box::new(
1301 crate::components::command_palette::CommandPaletteModal::new(
1302 &s.leader_tree(),
1303 &gateway,
1304 s.icons(),
1305 tx.clone(),
1306 ),
1307 )
1308 }
1309 OverlayOpen::WorkspaceSwitcher => Box::new(ActiveDialog::workspace_switcher(&s)),
1311 OverlayOpen::ThemePicker => Box::new(ActiveDialog::theme_picker(&s)),
1314 OverlayOpen::Help => Box::new(ActiveDialog::help(&s.key_bindings, &s.leader_tree())),
1315 OverlayOpen::QueryHelp | OverlayOpen::QueryHelpOverBrowser => {
1316 Box::new(ActiveDialog::query_syntax())
1317 }
1318 OverlayOpen::Cheatsheet => Box::new(ActiveDialog::cheatsheet(&s)),
1319 OverlayOpen::SortQuery => return self.sort_dialog(SortTarget::Query, tx),
1320 OverlayOpen::SortSidebar => return self.sort_dialog(SortTarget::Sidebar, tx),
1321 OverlayOpen::SortBrowser => return self.sort_dialog(SortTarget::Browser, tx),
1322 OverlayOpen::QuickNote => Box::new(ActiveDialog::quick_note(self.vault.clone())),
1323 OverlayOpen::PinnedNotes => {
1325 Box::new(ActiveDialog::pinned_notes(self.vault.clone(), tx))
1326 }
1327 };
1328 Some(overlay)
1329 }
1330
1331 fn handle_update(&mut self, flow: UpdateFlow, tx: &AppTx) {
1335 match flow {
1336 UpdateFlow::Available(status) => {
1337 self.update = Some(status);
1338 }
1339 UpdateFlow::ShowDialog => {
1340 if let Some(status) = self.update.clone() {
1341 self.present_overlay(Box::new(ActiveDialog::update(&status)));
1342 }
1343 }
1344 UpdateFlow::Dismiss(_) => {
1345 self.update = None;
1347 }
1348 UpdateFlow::Applied => {
1349 self.update = None;
1352 }
1353 UpdateFlow::Apply => {
1354 let tx2 = tx.clone();
1355 tx.send(AppEvent::FlashMessage("Downloading update…".into()))
1356 .ok();
1357 tokio::spawn(async move {
1358 let result = async {
1359 let latest = crate::update::latest_release().await?;
1360 crate::update::install(latest).await
1361 }
1362 .await;
1363 let msg = match result {
1364 Ok(()) => {
1365 tx2.send(AppEvent::Update(UpdateFlow::Applied)).ok();
1366 "Update installed — restart kimün to apply".to_string()
1367 }
1368 Err(e) => format!("Update failed: {e}"),
1369 };
1370 tx2.send(AppEvent::FlashMessage(msg)).ok();
1371 });
1372 }
1373 }
1374 }
1375
1376 async fn handle_file_op(&mut self, op: FileOp, tx: &AppTx) {
1379 match op {
1380 FileOp::ShowMenu(path) => {
1381 self.present_overlay(Box::new(ActiveDialog::file_ops_menu(path)));
1382 }
1383 FileOp::ShowProperties(path) => {
1384 if path.is_like(&self.path) {
1387 self.try_save().await;
1388 if self.panels.editor().is_some_and(|e| e.is_dirty()) {
1389 self.footer
1390 .flash("save failed — properties not opened".to_string(), tx);
1391 return;
1392 }
1393 }
1394 self.present_overlay(Box::new(ActiveDialog::properties(
1395 path,
1396 self.vault.clone(),
1397 tx,
1398 )));
1399 }
1400 FileOp::ShowDelete(path) => {
1401 self.present_overlay(Box::new(ActiveDialog::delete(path, self.vault.clone())));
1402 }
1403 FileOp::ShowRename(path) => {
1404 self.present_overlay(Box::new(ActiveDialog::rename(path, self.vault.clone())));
1405 }
1406 FileOp::ShowMove(path) => {
1407 self.present_overlay(Box::new(ActiveDialog::move_to(
1408 path,
1409 self.vault.clone(),
1410 tx,
1411 )));
1412 }
1413 FileOp::ShowCreateWithContent { path, content } => {
1414 let path = self.vault.free_note_path(&path).await;
1422 self.present_overlay(Box::new(ActiveDialog::create_note(
1423 path,
1424 self.vault.clone(),
1425 Some(content),
1426 )));
1427 }
1428 FileOp::Created(path) => {
1429 self.refresh_sidebar_if_showing(&path.get_parent_path().0, tx);
1433 }
1434 FileOp::Deleted(path) => {
1435 self.on_entry_op(path, tx).await;
1436 }
1437 FileOp::Renamed { from, to } => {
1438 if from.is_note() {
1441 self.on_note_renamed(from, to, tx).await;
1442 } else {
1443 self.on_entry_op(from, tx).await;
1444 }
1445 }
1446 FileOp::Moved { from, .. } => {
1447 self.on_entry_op(from, tx).await;
1448 }
1449 }
1450 }
1451
1452 fn handle_saved_search(&mut self, flow: SavedSearchFlow, tx: &AppTx) {
1454 match flow {
1455 SavedSearchFlow::Selected { query, name } => {
1456 self.overlays.close();
1460 self.apply_saved_search(query, name, tx);
1461 }
1462 SavedSearchFlow::Confirmed {
1463 name,
1464 query,
1465 source,
1466 } => {
1467 let vault = self.vault.clone();
1471 let tx = tx.clone();
1472 tokio::spawn(async move {
1473 match vault.save_search(&name, &query).await {
1474 Ok(()) => {
1475 tx.send(AppEvent::SavedSearch(SavedSearchFlow::Persisted {
1476 name,
1477 query,
1478 source,
1479 }))
1480 .ok();
1481 }
1482 Err(e) => {
1483 tracing::warn!("failed to save search '{}': {}", name, e);
1484 tx.send(AppEvent::SavedSearch(SavedSearchFlow::SaveFailed { name }))
1485 .ok();
1486 }
1487 }
1488 });
1489 }
1490 SavedSearchFlow::Persisted {
1497 name,
1498 query,
1499 source: SaveSource::QueryPanel,
1500 } if name.trim() != query.trim() => {
1501 self.panels.query_mut().repin_saved_search(name, &query);
1502 }
1503 SavedSearchFlow::Persisted { .. } => {}
1504 SavedSearchFlow::SaveFailed { name } => {
1505 self.footer
1506 .flash(format!("Failed to save search '{name}'"), tx);
1507 }
1508 }
1509 }
1510
1511 async fn handle_owned_message(&mut self, msg: AppEvent, tx: &AppTx) {
1514 match msg {
1515 AppEvent::RagStatus(status) => {
1516 let ask_was = self.rag_status.llm_available();
1517 let sem_was = self.rag_status.search_available();
1518 self.rag_status = status;
1519 let ask_now = status.llm_available();
1525 let sem_now = status.search_available();
1526 if ask_was != ask_now || sem_was != sem_now {
1527 let (kb, icons) = {
1528 let s = self.settings.read().unwrap();
1529 (s.key_bindings.clone(), s.icons())
1530 };
1531 self.panels.rebuild_rail(
1532 kb,
1533 icons,
1534 crate::components::activity_rail::RailCaps {
1535 semantic: sem_now,
1536 ask: ask_now,
1537 },
1538 );
1539 }
1540 if ask_was != ask_now {
1541 self.ask
1542 .refresh_capability(&mut self.panels, self.rag_status)
1543 .await;
1544 }
1545 }
1546 AppEvent::ServerUpdate(latest) => self.server_update = latest,
1547 AppEvent::Ask(data) => self.ask.handle_data(&mut self.panels, data, tx),
1548 AppEvent::FlashMessage(msg) => {
1551 self.footer.flash(msg, tx);
1552 }
1553 AppEvent::OpenAttachmentExternally => self.open_attachment_externally(tx),
1554 AppEvent::OpenQueryHelp => {
1555 let open = if self.overlays.active_takes_query_syntax() {
1558 Some(OverlayOpen::QueryHelpOverBrowser)
1559 } else if !self.overlays.is_open() {
1560 Some(OverlayOpen::QueryHelp)
1561 } else {
1562 None
1563 };
1564 if let Some(open) = open {
1565 self.open_overlay(open, tx);
1566 }
1567 }
1568 AppEvent::OpenSortDialog(target) => {
1569 let open = match target {
1573 SortTarget::Sidebar | SortTarget::Query if self.overlays.is_open() => None,
1574 SortTarget::Sidebar => Some(OverlayOpen::SortSidebar),
1575 SortTarget::Query => Some(OverlayOpen::SortQuery),
1576 SortTarget::Browser => self
1577 .overlays
1578 .active_sortable()
1579 .is_some()
1580 .then_some(OverlayOpen::SortBrowser),
1581 };
1582 if let Some(open) = open {
1583 self.open_overlay(open, tx);
1584 }
1585 }
1586 AppEvent::ExecuteLeaderAction(action) => {
1587 if self.overlays.is_open() {
1588 tracing::warn!("ExecuteLeaderAction({action:?}) dropped: overlay open");
1591 } else {
1592 self.execute_leader_action(action, tx);
1593 }
1594 }
1595 AppEvent::ApplyTheme { theme, persist } => {
1596 {
1599 let mut s = self.settings.write().unwrap();
1600 s.set_theme(theme.name.clone());
1601 }
1602 self.theme = (*theme).adapt_to_terminal();
1603 if persist {
1604 let snapshot = self.settings.read().unwrap().clone();
1605 tokio::spawn(async move {
1606 snapshot.save_to_disk().ok();
1607 });
1608 }
1609 tx.send(AppEvent::Redraw).ok();
1610 }
1611 AppEvent::RunTagQuery(label) if !self.overlays.is_open() => {
1614 let term = kimun_core::quote_query_term(&label);
1616 self.open_find_with_query(format!("#{term}"), None, tx);
1617 }
1618 AppEvent::JumpToHeading(target) if !self.overlays.is_open() => {
1619 self.panels.sync_outline(&self.path, tx);
1623 let row = self.panels.outline_mut().resolve(&target);
1624 match (self.panels.editor_mut(), row) {
1625 (Some(ed), Some(row)) => ed.jump_to_row(row),
1626 (None, _) => self.footer.flash("no note open".to_string(), tx),
1629 (Some(_), None) => self.footer.flash(
1630 format!("heading \"{}\" is no longer in the note", target.text),
1631 tx,
1632 ),
1633 }
1634 self.focus_editor();
1635 }
1636 AppEvent::OpenDrawerView(view) => {
1637 if view == DrawerView::Semantic && !self.rag_status.search_available() {
1641 let msg = if crate::rag::rag_configured(&self.settings) {
1642 "Semantic search needs a reachable server with an embedder"
1643 } else {
1644 "Set kimun_server_url in config to use semantic search"
1645 };
1646 tx.send(AppEvent::FlashMessage(msg.into())).ok();
1647 }
1648 else if view == DrawerView::Ask && !self.rag_status.llm_available() {
1651 tx.send(AppEvent::FlashMessage(
1652 "Ask needs an LLM-configured server; this one is semantic-search only"
1653 .into(),
1654 ))
1655 .ok();
1656 }
1657 else if self.panels.is_visible(PanelKind::Drawer)
1660 && self.panels.active_drawer_view() == view
1661 {
1662 self.panels.hide(PanelKind::Drawer);
1663 } else {
1664 self.open_drawer_view(view, tx);
1665 }
1666 }
1667 AppEvent::CloseOverlay => {
1668 self.dismiss_overlay();
1677 }
1678 AppEvent::SortChanged {
1679 target,
1680 state,
1681 persist,
1682 } => {
1683 self.apply_sort(target, &state, tx);
1684 if persist && target == SortTarget::Sidebar {
1688 if matches!(state.field, SortField::Property(_)) {
1689 tx.send(AppEvent::FlashMessage(
1690 "property sort can't be saved as default".into(),
1691 ))
1692 .ok();
1693 } else {
1694 self.persist_sidebar_sort(&state);
1695 }
1696 }
1697 }
1698 AppEvent::Autosave => {
1699 self.spawn_autosave(tx);
1700 }
1701 AppEvent::NoteReloadFromDisk(path) => {
1702 if path.is_like(&self.path) && self.panels.editor().is_some_and(|e| e.is_dirty()) {
1706 self.footer.flash(
1707 "properties changed on disk — reopen the note to see them".to_string(),
1708 tx,
1709 );
1710 self.doc_meta.refresh_properties(&self.path, tx);
1712 } else if path.is_like(&self.path) {
1713 self.autosave_task.abort();
1714 match self.vault.get_note_text(&self.path).await {
1715 Ok(text) => {
1716 if let Some(ed) = self.panels.editor_mut() {
1717 ed.set_text(text.clone());
1718 ed.mark_saved(text);
1719 }
1720 }
1721 Err(_) => self.footer.flash(
1724 "reload failed — reopen the note before editing".to_string(),
1725 tx,
1726 ),
1727 }
1728 self.doc_meta.refresh_properties(&self.path, tx);
1729 }
1730 }
1731 AppEvent::AutosaveCompleted {
1732 path,
1733 saved_revision,
1734 title,
1735 } => {
1736 if path == self.path
1737 && let Some(rev) = saved_revision
1738 && let Some(ed) = self.panels.editor_mut()
1739 {
1740 ed.mark_saved_at_revision(rev);
1741 }
1742 if let Some(raw_title) = title {
1743 self.note_saved(&path, raw_title);
1744 }
1745 self.doc_meta.refresh_git(tx);
1748 if path == self.path {
1749 self.doc_meta.refresh_properties(&path, tx);
1750 if saved_revision.is_some() && self.drawer_open_on(DrawerView::Outline) {
1755 self.panels.sync_outline(&path, tx);
1756 }
1757 }
1758 }
1767 AppEvent::FocusSidebar => {
1768 self.focus_sidebar(tx);
1769 }
1770 AppEvent::FollowLink(target) => {
1771 self.follow_link(target, tx).await;
1772 }
1773 AppEvent::FollowLabel(name) => {
1774 let initial = format!("#{name}");
1775 let s = self.settings.read().unwrap();
1776 let provider = resolving_search_source(
1777 self.vault.clone(),
1778 s.current_last_paths(),
1779 Some(self.path.clone()),
1780 );
1781 let modal = NoteBrowserModal::with_initial_query(
1782 "Note Browser",
1783 BrowserScope::Query,
1784 provider,
1785 self.vault.clone(),
1786 s.key_bindings.clone(),
1787 s.icons(),
1788 tx.clone(),
1789 initial,
1790 );
1791 drop(s);
1792 self.present_overlay(Box::new(modal));
1793 }
1794 AppEvent::InsertAtCursor(text) if self.panels.focused() == PanelKind::Editor => {
1795 if let Some(ed) = self.panels.editor_mut() {
1796 ed.insert_at_cursor(&text, tx);
1797 }
1798 }
1799 _ => {}
1800 }
1801 }
1802
1803 pub fn focus_editor(&mut self) {
1804 self.panels.focus(PanelKind::Editor);
1805 }
1806
1807 fn drawer_open_on(&self, view: DrawerView) -> bool {
1809 self.panels.is_visible(PanelKind::Drawer) && self.panels.active_drawer_view() == view
1810 }
1811
1812 fn reflect_open_note_in_drawers(&mut self, tx: &AppTx) {
1817 let path = self.path.clone();
1818 if self.drawer_open_on(DrawerView::Find) {
1819 self.panels.query_mut().set_note(path.clone(), tx.clone());
1820 }
1821 if self.panels.is_visible(PanelKind::Drawer) {
1822 match self.panels.active_drawer_view() {
1823 DrawerView::Links => self.panels.links_mut().set_note(path.clone(), tx),
1824 DrawerView::Outline => self.panels.sync_outline(&path, tx),
1825 _ => {}
1826 }
1827 }
1828 }
1829
1830 fn reveal_note_dir_in_sidebar(&mut self, tx: &AppTx) {
1834 let note_parent = self.path.get_parent_path().0;
1835 if self.panels.sidebar().is_empty()
1836 || !note_parent.is_like(self.panels.sidebar().current_dir())
1837 {
1838 self.navigate_sidebar(note_parent, tx);
1839 }
1840 }
1841
1842 pub fn focus_sidebar(&mut self, tx: &AppTx) {
1846 if !self.panels.is_visible(PanelKind::Drawer) {
1847 self.open_drawer_view(DrawerView::Files, tx);
1851 } else {
1852 self.panels.focus(PanelKind::Drawer);
1853 }
1854 }
1855
1856 fn open_drawer_view(&mut self, view: DrawerView, tx: &AppTx) {
1860 self.ask.transition(&mut self.panels, view, tx);
1864 let newly_shown = !self.drawer_open_on(view);
1865 self.panels.open_drawer_view(view);
1866 self.drawer_view_revealed(view, newly_shown, tx);
1867 if view == DrawerView::Ask {
1871 self.panels.focus(PanelKind::Editor);
1872 } else {
1873 self.panels.focus(PanelKind::Drawer);
1874 }
1875 }
1876
1877 fn ensure_ask_workspace(&mut self, tx: &AppTx) {
1881 if !self.panels.is_showing_ask() {
1882 self.open_drawer_view(DrawerView::Ask, tx);
1883 }
1884 }
1885
1886 fn with_ask_panel(&mut self, tx: &AppTx, f: impl FnOnce(&mut ThreadPanel, &AppTx)) {
1890 self.ensure_ask_workspace(tx);
1891 f(self.panels.ask_mut(), tx);
1892 }
1893
1894 fn drawer_view_revealed(&mut self, view: DrawerView, newly_shown: bool, tx: &AppTx) {
1902 match view {
1903 DrawerView::Find if newly_shown => {
1904 self.panels
1905 .query_mut()
1906 .set_note(self.path.clone(), tx.clone());
1907 }
1908 DrawerView::Files if newly_shown => {
1909 self.reveal_note_dir_in_sidebar(tx);
1910 }
1911 DrawerView::Config => {
1912 let info = {
1913 let s = self.settings.read().unwrap();
1914 let key_of = |a: &ActionShortcuts| {
1915 s.key_bindings
1916 .first_combo_for(a)
1917 .unwrap_or_else(|| "unbound".to_string())
1918 };
1919 crate::components::drawer::ConfigInfo {
1920 theme_name: s.get_theme().name,
1921 leader_key: key_of(&ActionShortcuts::Leader),
1922 preferences_key: key_of(&ActionShortcuts::OpenPreferences),
1923 leader_timeout_ms: s.leader_timeout_ms,
1924 config_path: s
1925 .config_file
1926 .as_ref()
1927 .map(|p| p.display().to_string())
1928 .unwrap_or_else(|| "default location".to_string()),
1929 }
1930 };
1931 self.panels.drawer_set_config_info(info);
1932 }
1933 DrawerView::Semantic => self.panels.semantic_mut().ensure_source(tx),
1934 DrawerView::Tags => self.panels.tags_mut().refresh(tx),
1935 DrawerView::Links => self.panels.links_mut().set_note(self.path.clone(), tx),
1936 DrawerView::Outline => self.panels.sync_outline(&self.path, tx),
1937 _ => {}
1938 }
1939 }
1940
1941 fn apply_text_from_leader(&mut self, action: TextAction, tx: &AppTx) {
1951 if self.input_ctx(false).accepts_text_action() {
1952 self.run_op(EditorOp::ApplyText(action), tx);
1953 } else {
1954 self.footer
1955 .flash("formatting needs the note focused".to_string(), tx);
1956 }
1957 }
1958
1959 fn focus_left(&mut self, _tx: &AppTx) {
1961 if let Some(kind) = self.panels.prev_kind() {
1962 self.panels.focus(kind);
1963 }
1964 }
1965
1966 fn focus_right(&mut self, _tx: &AppTx) {
1968 if let Some(kind) = self.panels.next_kind() {
1969 self.panels.focus(kind);
1970 }
1971 }
1972
1973 fn toggle_drawer(&mut self, tx: &AppTx) {
1980 if self.panels.is_visible(PanelKind::Drawer) {
1981 self.panels.hide(PanelKind::Drawer);
1982 } else {
1983 self.panels.show(PanelKind::Drawer);
1984 self.drawer_view_revealed(self.panels.active_drawer_view(), true, tx);
1985 }
1986 }
1987
1988 fn save_query_source(&self) -> Option<(String, Option<String>, SaveSource)> {
1994 let (query, provenance, source) = match self.overlays.active_kind() {
1995 Some(OverlayKind::NoteBrowser) => (
1996 self.overlays.active_query().unwrap_or_default().to_string(),
1997 self.overlays
1998 .active_saved_search_provenance()
1999 .map(str::to_string),
2000 SaveSource::NoteBrowser,
2001 ),
2002 None => (
2003 self.panels.query().active_query().to_string(),
2004 self.panels.query().saved_search_name().map(str::to_string),
2005 SaveSource::QueryPanel,
2006 ),
2007 Some(_) => return None,
2008 };
2009 if query.trim().is_empty() {
2010 None
2011 } else {
2012 Some((query, provenance, source))
2013 }
2014 }
2015
2016 fn schedule_whichkey_reveal(&self, tx: &AppTx) {
2021 let timeout = {
2022 let s = self.settings.read().unwrap();
2023 std::time::Duration::from_millis(s.leader_timeout_ms)
2024 };
2025 let tx2 = tx.clone();
2026 tokio::spawn(async move {
2027 tokio::time::sleep(timeout + std::time::Duration::from_millis(10)).await;
2028 let _ = tx2.send(AppEvent::Redraw);
2029 });
2030 }
2031
2032 fn open_ask_workspace(&mut self, tx: &AppTx) {
2041 if self.overlays.is_open() {
2042 return;
2043 }
2044 if !self.panels.is_showing_ask() {
2045 if !self.rag_status.llm_available() {
2046 tx.send(AppEvent::FlashMessage(
2047 "Ask needs an LLM-configured server; this one is semantic-search only".into(),
2048 ))
2049 .ok();
2050 return;
2051 }
2052 self.open_drawer_view(DrawerView::Ask, tx);
2053 }
2054 self.panels.ask_mut().focus_composer();
2055 self.panels.focus(PanelKind::Editor);
2056 }
2057
2058 fn follow_link_at_cursor(&mut self, tx: &AppTx) -> bool {
2066 use crate::components::text_editor::FollowTarget;
2067 if self.panels.is_showing_attachment() {
2070 self.open_attachment_externally(tx);
2071 return true;
2072 }
2073 let Some(editor) = self.panels.editor_mut() else {
2074 return false;
2075 };
2076 match editor.follow_target_at_cursor() {
2077 Some(FollowTarget::Link(target)) => {
2078 tx.send(AppEvent::FollowLink(target)).ok();
2079 true
2080 }
2081 Some(FollowTarget::Label(name)) => {
2082 tx.send(AppEvent::FollowLabel(name)).ok();
2083 true
2084 }
2085 None => false,
2086 }
2087 }
2088
2089 fn open_attachment_externally(&mut self, tx: &AppTx) {
2092 let Some(path) = self.panels.attachment_path() else {
2093 return;
2094 };
2095 let os_path = self.vault.path_to_pathbuf(path);
2096 match open::that_detached(&os_path) {
2097 Ok(()) => self
2098 .footer
2099 .flash(format!("Opening {}", os_path.display()), tx),
2100 Err(e) => self.footer.flash(format!("Cannot open: {e}"), tx),
2101 }
2102 }
2103
2104 fn handle_leader_key(
2109 &mut self,
2110 key: &ratatui::crossterm::event::KeyEvent,
2111 tx: &AppTx,
2112 ) -> EventState {
2113 use ratatui::crossterm::event::{KeyCode, KeyModifiers};
2114 match key.code {
2115 KeyCode::Esc => {
2116 self.leader.cancel();
2117 self.focus_editor();
2118 }
2119 KeyCode::Backspace => {
2120 let outcome = self.leader.step_up();
2121 if outcome == LeaderOutcome::SteppedUp {
2123 self.schedule_whichkey_reveal(tx);
2124 }
2125 }
2126 KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
2127 match self.leader.feed(c) {
2128 LeaderOutcome::Fired(action) => self.execute_leader_action(action, tx),
2129 LeaderOutcome::Invalid => {
2130 self.footer.flash(format!("leader: no entry for '{c}'"), tx);
2132 self.schedule_whichkey_reveal(tx);
2133 }
2134 LeaderOutcome::Descended => self.schedule_whichkey_reveal(tx),
2135 _ => {}
2136 }
2137 }
2138 _ => {}
2142 }
2143 tx.send(AppEvent::Redraw).ok();
2144 EventState::Consumed
2145 }
2146
2147 fn execute_leader_action(&mut self, action: LeaderAction, tx: &AppTx) {
2150 match action {
2151 LeaderAction::OpenDrawer(view) => self.open_drawer_view(view, tx),
2152
2153 LeaderAction::AppCheckUpdates => {
2154 if let Some(status) = self.update.clone() {
2155 self.present_overlay(Box::new(ActiveDialog::update(&status)));
2156 } else {
2157 let tx2 = tx.clone();
2159 tx.send(AppEvent::FlashMessage("Checking for updates…".into()))
2160 .ok();
2161 tokio::spawn(async move {
2162 let Ok(config_dir) = crate::settings::config_dir() else {
2163 return;
2164 };
2165 match crate::update::check_now(config_dir, true).await {
2166 Ok(Some(status)) if status.update_available => {
2167 tx2.send(AppEvent::Update(UpdateFlow::Available(status)))
2171 .ok();
2172 tx2.send(AppEvent::Update(UpdateFlow::ShowDialog)).ok();
2173 }
2174 Ok(_) => {
2175 tx2.send(AppEvent::FlashMessage("kimün is up to date".into()))
2176 .ok();
2177 }
2178 Err(e) => {
2179 tx2.send(AppEvent::FlashMessage(format!(
2180 "Update check failed: {e}"
2181 )))
2182 .ok();
2183 }
2184 }
2185 });
2186 }
2187 }
2188
2189 LeaderAction::FindFiles => self.open_overlay(OverlayOpen::FileFinder, tx),
2192 LeaderAction::FindGrep => self.open_overlay(OverlayOpen::SearchBrowser, tx),
2193 LeaderAction::FindTags => self.open_drawer_view(DrawerView::Tags, tx),
2194 LeaderAction::FindBacklinks => {
2195 self.open_find_with_query("<{note}".to_string(), None, tx)
2196 }
2197 LeaderAction::FindSaved => self.open_overlay(OverlayOpen::SavedSearches, tx),
2198 LeaderAction::FindRecent => self.open_overlay(OverlayOpen::SearchBrowser, tx),
2199 LeaderAction::FindHeadings => self.open_drawer_view(DrawerView::Outline, tx),
2200
2201 LeaderAction::NoteNew => {
2203 self.open_drawer_view(DrawerView::Files, tx);
2207 self.footer
2208 .flash("type a name — Enter creates".to_string(), tx);
2209 }
2210 LeaderAction::NoteDaily => {
2211 tx.send(AppEvent::OpenJournal).ok();
2212 }
2213 LeaderAction::NoteFromTemplate => {
2214 self.footer.flash("templates — coming soon".to_string(), tx);
2215 }
2216 LeaderAction::NoteRename => {
2217 if let Some(path) = self.open_note_or_flash(tx) {
2218 tx.send(AppEvent::FileOp(FileOp::ShowRename(path))).ok();
2219 }
2220 }
2221 LeaderAction::NoteMove => {
2222 if let Some(path) = self.open_note_or_flash(tx) {
2223 tx.send(AppEvent::FileOp(FileOp::ShowMove(path))).ok();
2224 }
2225 }
2226 LeaderAction::NoteProperties => {
2227 if let Some(path) = self.open_note_or_flash(tx) {
2228 tx.send(AppEvent::FileOp(FileOp::ShowProperties(path))).ok();
2229 }
2230 }
2231 LeaderAction::NoteDelete => {
2232 if let Some(path) = self.open_note_or_flash(tx) {
2233 tx.send(AppEvent::FileOp(FileOp::ShowDelete(path))).ok();
2234 }
2235 }
2236
2237 LeaderAction::LinksTab(tab) => {
2239 self.open_drawer_view(DrawerView::Links, tx);
2240 self.panels.links_mut().show_tab(tab, tx);
2241 }
2242 LeaderAction::LinksGraph => {
2243 self.footer
2244 .flash("local graph — coming soon".to_string(), tx);
2245 }
2246
2247 LeaderAction::GitStatus => {
2250 self.doc_meta.refresh_git(tx);
2251 let msg = self
2252 .doc_meta
2253 .git()
2254 .cloned()
2255 .unwrap_or_else(|| "not a git repository".to_string());
2256 self.footer.flash(msg, tx);
2257 }
2258 LeaderAction::GitSync | LeaderAction::GitLog | LeaderAction::GitDiff => {
2259 self.footer
2260 .flash("git is display-only for now".to_string(), tx);
2261 }
2262
2263 LeaderAction::VaultSwitch => self.open_overlay(OverlayOpen::WorkspaceSwitcher, tx),
2265 LeaderAction::VaultReindex => {
2266 let vault = self.vault.clone();
2269 let tx2 = tx.clone();
2270 self.footer.flash("reindexing…".to_string(), tx);
2271 tokio::spawn(async move {
2272 let started = std::time::Instant::now();
2273 let result = vault.index_notes(kimun_core::NotesValidation::Fast).await;
2274 let msg = match result {
2275 Ok(_) => format!("reindexed in {:.1?}", started.elapsed()),
2276 Err(e) => format!("reindex failed: {e}"),
2277 };
2278 tx2.send(AppEvent::FlashMessage(msg)).ok();
2279 });
2280 }
2281 LeaderAction::VaultConfig => self.open_drawer_view(DrawerView::Config, tx),
2284 LeaderAction::VaultTheme => self.open_overlay(OverlayOpen::ThemePicker, tx),
2285 LeaderAction::VaultPreferences => {
2286 tx.send(AppEvent::OpenScreen(ScreenEvent::OpenPreferences))
2287 .ok();
2288 }
2289 LeaderAction::AppOnboarding => {
2290 tx.send(AppEvent::OpenScreen(ScreenEvent::OpenOnboarding))
2291 .ok();
2292 }
2293
2294 LeaderAction::WindowZen => {
2296 self.panels.hide(PanelKind::Drawer);
2297 self.focus_editor();
2298 }
2299 LeaderAction::WindowSplit => {
2300 self.footer
2301 .flash("editor splits — coming soon".to_string(), tx);
2302 }
2303 LeaderAction::WindowGrowDrawer => self.panels.adjust_drawer_width(4),
2304 LeaderAction::WindowShrinkDrawer => self.panels.adjust_drawer_width(-4),
2305
2306 LeaderAction::NoteToggleTodo => {
2308 self.footer
2309 .flash("toggle todo — coming soon".to_string(), tx);
2310 }
2311 LeaderAction::NotePreview => {
2312 self.footer
2313 .flash("preview — lands with phase 09".to_string(), tx);
2314 }
2315 LeaderAction::NoteCopyWikilink => {
2316 if let Some(path) = self.open_note_or_flash(tx) {
2317 let link = format!("[[{}]]", path.get_clean_name());
2318 crate::components::yank(link, "wikilink copied", tx);
2319 }
2320 }
2321 LeaderAction::NoteExport => {
2322 self.footer.flash("export — coming soon".to_string(), tx);
2323 }
2324 LeaderAction::NoteYankPath => {
2325 if let Some(path) = self.open_note_or_flash(tx) {
2326 crate::components::yank(path.to_string(), "note path copied", tx);
2327 }
2328 }
2329
2330 LeaderAction::TextBold => self.apply_text_from_leader(TextAction::Bold, tx),
2336 LeaderAction::TextItalic => self.apply_text_from_leader(TextAction::Italic, tx),
2337 LeaderAction::TextStrikethrough => {
2338 self.apply_text_from_leader(TextAction::Strikethrough, tx)
2339 }
2340
2341 LeaderAction::AskFocus => self.open_ask_workspace(tx),
2343 LeaderAction::AskNew => {
2344 self.with_ask_panel(tx, |panel, _tx| {
2345 panel.thread_mut().clear();
2346 panel.focus_composer();
2347 });
2348 self.panels.ask_sources_mut().reset(tx);
2349 }
2350 LeaderAction::AskCopy => {
2351 self.with_ask_panel(tx, |panel, tx| panel.copy_selected(tx));
2352 }
2353 LeaderAction::AskSave => {
2354 self.with_ask_panel(tx, |panel, tx| panel.save_selected(tx));
2355 }
2356 LeaderAction::AskRegenerate => {
2357 self.with_ask_panel(tx, |panel, tx| panel.regenerate_selected(tx));
2358 self.ask.sync_sources(&mut self.panels, tx);
2359 }
2360 LeaderAction::AskSource => {
2361 self.ensure_ask_workspace(tx);
2362 self.ask.sync_sources_from_selected(&mut self.panels, tx);
2365 self.panels.ask_sources_mut().open_reader(0, tx);
2366 self.panels.focus(PanelKind::Drawer);
2367 }
2368
2369 LeaderAction::Palette => self.open_overlay(OverlayOpen::CommandPalette, tx),
2370 LeaderAction::Help => self.open_overlay(OverlayOpen::Cheatsheet, tx),
2371
2372 LeaderAction::NoteSave => {
2373 self.spawn_autosave(tx);
2376 }
2377 LeaderAction::AppQuit => {
2378 tx.send(AppEvent::Quit).ok();
2379 }
2380
2381 LeaderAction::FindPinned => self.open_overlay(OverlayOpen::PinnedNotes, tx),
2383 LeaderAction::NoteTogglePin => self.toggle_pin_open_note(tx),
2384 LeaderAction::PinnedJump(n) => self.jump_to_pinned(n, tx),
2385 }
2386 }
2387
2388 fn open_find_with_query(&mut self, query: String, name: Option<String>, tx: &AppTx) {
2398 self.panels.open_drawer_view(DrawerView::Find);
2399 self.panels.query_mut().apply_query(query, name, tx.clone());
2400 self.panels.focus(PanelKind::Drawer);
2401 }
2402
2403 fn apply_saved_search(&mut self, query: String, name: String, tx: &AppTx) {
2404 self.open_find_with_query(query, Some(name), tx);
2409 }
2410
2411 fn toggle_backlinks(&mut self, tx: &AppTx) {
2412 if self.drawer_open_on(DrawerView::Find) {
2413 self.panels.hide(PanelKind::Drawer);
2414 } else {
2415 self.open_drawer_view(DrawerView::Find, tx);
2416 }
2417 }
2418}
2419
2420#[async_trait]
2421impl AppScreen for EditorScreen {
2422 fn get_kind(&self) -> ScreenKind {
2423 ScreenKind::Editor
2424 }
2425
2426 async fn on_enter(&mut self, tx: &AppTx) {
2427 self.app_tx = Some(tx.clone());
2428 self.open_path(self.path.clone(), None, tx).await;
2429 }
2430
2431 fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
2432 if let InputEvent::Mouse(m) = event {
2440 self.pointer = Some(ratatui::layout::Position::new(m.column, m.row));
2441 }
2442 self.overlays.observe_input(event);
2443 let double_click = self.track_click(event, std::time::Instant::now());
2444 let ctx = self.input_ctx_for(event, double_click);
2445 let classification = if matches!(event, InputEvent::Key(_)) {
2449 let s = self.settings.read().unwrap();
2450 classify(event, &s.key_bindings, &ctx)
2451 } else {
2452 classify(event, &KeyBindings::empty(), &ctx)
2453 };
2454 self.apply_classification(classification, event, tx)
2455 }
2456
2457 fn render(&mut self, f: &mut ratatui::Frame) {
2458 crate::components::clickable::clear();
2461 let theme = &self.theme;
2462 f.render_widget(
2463 ratatui::widgets::Block::default().style(theme.base_style()),
2464 f.area(),
2465 );
2466
2467 let rows = Layout::default()
2468 .direction(Direction::Vertical)
2469 .constraints([
2470 Constraint::Length(1),
2471 Constraint::Min(0),
2472 Constraint::Length(crate::components::footer_bar::STATUS_BAR_HEIGHT),
2473 ])
2474 .split(f.area());
2475
2476 let workspace_badge = {
2480 let s = self.settings.read().unwrap();
2481 s.workspace_config.as_ref().map(|wc| {
2482 ratatui::text::Line::from(vec![
2483 ratatui::text::Span::styled(
2484 self.icons.workspace,
2485 Style::default().fg(theme.accent.to_ratatui()),
2486 ),
2487 ratatui::text::Span::styled(
2488 format!(" {}", wc.global.current_workspace),
2489 Style::default().fg(theme.gray.to_ratatui()),
2490 ),
2491 ])
2492 })
2493 };
2494 const GAP: u16 = 3;
2499 let budget = rows[0].width / 2;
2500 let badge_w_full = workspace_badge
2501 .as_ref()
2502 .map(|b| b.width() as u16)
2503 .unwrap_or_default();
2504 let width_of = |palette: &str, badge: u16| {
2505 unicode_width::UnicodeWidthStr::width(palette) as u16
2506 + if badge > 0 { GAP + badge } else { 0 }
2507 + 2
2508 };
2509 let (palette_label, workspace_badge) = if width_of("≡ Commands", badge_w_full) <= budget {
2510 ("≡ Commands", workspace_badge)
2511 } else if width_of("≡", badge_w_full) <= budget {
2512 ("≡", workspace_badge)
2513 } else {
2514 ("≡", None)
2515 };
2516 let palette_w = unicode_width::UnicodeWidthStr::width(palette_label) as u16;
2517 let badge_w = workspace_badge
2518 .as_ref()
2519 .map(|b| b.width() as u16)
2520 .unwrap_or_default();
2521 let right_w = palette_w + if badge_w > 0 { GAP + badge_w } else { 0 };
2522 let breadcrumb = self
2523 .path
2524 .to_string()
2525 .trim_start_matches('/')
2526 .replace('/', " / ");
2527 let title_cols = Layout::default()
2528 .direction(Direction::Horizontal)
2529 .constraints([Constraint::Min(0), Constraint::Length(right_w + 2)])
2530 .split(rows[0]);
2531 f.render_widget(
2532 Paragraph::new(ratatui::text::Line::from(vec![
2533 ratatui::text::Span::styled(
2534 " Kimün ",
2535 Style::default()
2536 .fg(theme.accent.to_ratatui())
2537 .add_modifier(ratatui::style::Modifier::BOLD),
2538 ),
2539 ratatui::text::Span::styled(
2540 format!("─ {breadcrumb}"),
2541 Style::default().fg(theme.fg_secondary.to_ratatui()),
2542 ),
2543 ])),
2544 title_cols[0],
2545 );
2546 let right = Rect {
2549 x: title_cols[1].x + 2.min(title_cols[1].width),
2550 width: title_cols[1].width.saturating_sub(2),
2551 ..title_cols[1]
2552 };
2553 self.title_targets.clear();
2554 let mut target = |x: u16, w: u16, t: TitleTarget| {
2555 if x < right.right() {
2556 let w = w.min(right.right() - x);
2557 self.title_targets.push((Rect::new(x, right.y, w, 1), t));
2558 }
2559 };
2560 target(right.x, palette_w, TitleTarget::Palette);
2561 let mut spans = vec![ratatui::text::Span::styled(palette_label, theme.action())];
2562 if let Some(badge) = workspace_badge {
2563 target(right.x + palette_w + GAP, badge_w, TitleTarget::Workspace);
2564 spans.push(ratatui::text::Span::raw(" ".repeat(GAP as usize)));
2565 let mut badge_spans = badge.spans;
2567 if let Some(name) = badge_spans.last_mut() {
2568 name.style = theme.action();
2569 }
2570 spans.extend(badge_spans);
2571 }
2572 for (r, _) in &self.title_targets {
2573 crate::components::clickable::register(*r);
2574 }
2575 f.render_widget(Paragraph::new(ratatui::text::Line::from(spans)), right);
2576
2577 self.panels
2580 .render(f, rows[1], theme, !self.overlays.is_open());
2581
2582 let (focus_label, hints) = if let Some(kind) = self.overlays.active_kind() {
2586 (kind.label(), self.overlays.hint_shortcuts())
2587 } else {
2588 (self.panels.focused_label(), self.panels.focused_hints())
2589 };
2590 let editing = if let Some(kind) = self.overlays.active_kind() {
2591 !matches!(kind, OverlayKind::Dialog)
2594 } else {
2595 match self.panels.focused() {
2596 PanelKind::Editor => true,
2597 PanelKind::Drawer => self.panels.drawer_is_text_input(),
2598 PanelKind::Rail => false,
2599 }
2600 };
2601 let path_str = self.path.to_string();
2602 let link_segment = if self.panels.focused() == PanelKind::Editor && !self.overlays.is_open()
2605 {
2606 let link = self
2607 .panels
2608 .editor()
2609 .and_then(|e| e.follow_target_at_cursor());
2610 self.doc_meta
2611 .link_segment(link.as_ref(), &self.path, self.app_tx.as_ref())
2612 } else {
2613 None
2614 };
2615 let ln_col = (self.panels.focused() == PanelKind::Editor && !self.overlays.is_open())
2618 .then(|| {
2619 self.panels.editor().map(|e| {
2620 let (row, col) = e.cursor_pos();
2621 (row + 1, col + 1)
2622 })
2623 })
2624 .flatten();
2625 let matches = (self.panels.focused() == PanelKind::Drawer
2627 && self.panels.active_drawer_view() == DrawerView::Find)
2628 .then(|| self.panels.query().result_count());
2629 let (global_hints, leader_timeout, gateway_label) = {
2630 let s = self.settings.read().unwrap();
2631 (
2632 crate::components::hints::global_hints(&s.key_bindings),
2633 std::time::Duration::from_millis(s.leader_timeout_ms),
2634 s.key_bindings
2635 .first_combo_for(&ActionShortcuts::Leader)
2636 .unwrap_or_else(|| "leader".to_string()),
2637 )
2638 };
2639 let ctx = crate::components::footer_bar::StatusContext {
2640 focus_label,
2641 editing,
2642 hints: &hints,
2643 global_hints: &global_hints,
2644 doc: crate::components::footer_bar::DocState {
2645 path: &path_str,
2646 dirty: self.panels.editor().is_some_and(|e| e.is_dirty()),
2647 ln_col,
2648 backlinks: self.doc_meta.backlinks(),
2649 props: self.open_note().and(self.doc_meta.properties()),
2650 git: self.doc_meta.git().cloned(),
2651 matches,
2652 link: link_segment,
2653 update: self
2654 .update
2655 .as_ref()
2656 .map(|u| format!("⬆ {} available", u.latest)),
2657 rag: self.rag_status.label().map(str::to_string),
2658 rag_update: matches!(
2661 self.rag_status,
2662 crate::rag::RagStatus::Online { .. } | crate::rag::RagStatus::Syncing { .. }
2663 )
2664 .then(|| self.server_update.as_ref())
2665 .flatten()
2666 .map(|u| format!("rag: {}", u.summary())),
2667 },
2668 };
2669 self.footer.render(f, rows[2], theme, &ctx);
2670
2671 let whichkey_visible = self
2674 .leader
2675 .pending_since()
2676 .is_some_and(|since| since.elapsed() >= leader_timeout);
2677 if whichkey_visible {
2678 let gateway = gateway_label;
2679 let area = f.area();
2680 let h = crate::components::which_key::desired_height(&self.leader, area.width)
2681 .min(rows[1].height);
2682 let rect =
2683 ratatui::layout::Rect::new(area.x, rows[2].y.saturating_sub(h), area.width, h);
2684 crate::components::clickable::occlude(rect);
2685 crate::components::which_key::render(f, rect, theme, &self.leader, &gateway);
2686 }
2687
2688 self.overlays.render(f, f.area(), &self.theme);
2690 crate::components::clickable::apply_hover(f.buffer_mut(), self.pointer, &self.theme);
2691 }
2692
2693 async fn handle_app_message(&mut self, msg: AppEvent, tx: &AppTx) {
2694 match msg {
2695 AppEvent::OverlayData(data) => {
2700 self.overlays.handle_data(&data, &self.vault, tx);
2701 }
2702 AppEvent::Update(flow) => self.handle_update(flow, tx),
2703 AppEvent::FileOp(op) => self.handle_file_op(op, tx).await,
2704 AppEvent::SavedSearch(flow) => self.handle_saved_search(flow, tx),
2705 msg => {
2706 let Some(msg) = self.doc_meta.handle(msg, &self.path) else {
2709 return;
2710 };
2711 self.handle_owned_message(msg, tx).await;
2712 }
2713 }
2714 }
2715
2716 async fn try_open_path(
2719 &mut self,
2720 path: VaultPath,
2721 emphasis: Option<Vec<String>>,
2722 tx: &AppTx,
2723 ) -> Option<VaultPath> {
2724 self.dismiss_overlay();
2725 if path.is_note() {
2726 self.open_path(path, emphasis, tx).await;
2727 self.focus_editor();
2728 } else {
2729 self.navigate_sidebar(path, tx);
2730 }
2731 None
2732 }
2733
2734 async fn try_open_attachment(&mut self, path: VaultPath, tx: &AppTx) -> Option<VaultPath> {
2735 self.dismiss_overlay();
2736 self.open_attachment(path, tx).await;
2737 None
2738 }
2739
2740 async fn on_exit(&mut self, _tx: &AppTx) {
2741 self.try_save().await;
2742 }
2743}
2744
2745#[cfg(test)]
2746mod tests {
2747 use super::*;
2748 use crate::keys::key_event_to_combo;
2749
2750 #[test]
2752 fn panel_kind_labels_and_overlay_kind_compile() {
2753 assert_eq!(PanelKind::Editor.label(), "EDITOR");
2754 assert_eq!(PanelKind::Rail.label(), "RAIL");
2755 assert_eq!(DrawerView::Files.label(), "FILES");
2756 assert_eq!(DrawerView::Find.label(), "FIND");
2757 let _kind = OverlayKind::Dialog;
2758 }
2759
2760 async fn test_screen() -> (
2763 EditorScreen,
2764 Arc<NoteVault>,
2765 SharedSettings,
2766 tempfile::TempDir,
2767 ) {
2768 use crate::settings::AppSettings;
2769 use kimun_core::VaultConfig;
2770 use std::sync::RwLock;
2771
2772 let dir = tempfile::TempDir::new().unwrap();
2773 let vault = Arc::new(
2774 NoteVault::new(VaultConfig::new(crate::test_support::sys(dir.path())))
2775 .await
2776 .unwrap(),
2777 );
2778 let settings: SharedSettings = Arc::new(RwLock::new(AppSettings::default()));
2779 let screen = EditorScreen::new(vault.clone(), VaultPath::root(), settings.clone());
2780 (screen, vault, settings, dir)
2781 }
2782
2783 async fn screen_on_note() -> (
2785 EditorScreen,
2786 Arc<NoteVault>,
2787 AppTx,
2788 tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
2789 tempfile::TempDir,
2790 ) {
2791 let (mut screen, vault, _settings, dir) = test_screen().await;
2792 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
2793 vault
2794 .create_note(&VaultPath::new("n.md"), "hi")
2795 .await
2796 .unwrap();
2797 screen.open_path(VaultPath::new("n.md"), None, &tx).await;
2798 (screen, vault, tx, rx, dir)
2799 }
2800
2801 #[tokio::test]
2802 async fn note_reload_from_disk_replaces_buffer_and_marks_clean() {
2803 let (mut screen, vault, tx, _rx, _dir) = screen_on_note().await;
2804 vault
2805 .save_note(&VaultPath::new("n.md"), "+++\na = 1\n+++\nbody")
2806 .await
2807 .unwrap();
2808 screen
2809 .handle_app_message(AppEvent::NoteReloadFromDisk(VaultPath::new("n.md")), &tx)
2810 .await;
2811 let ed = screen.panels.editor().unwrap();
2812 assert_eq!(ed.get_text(), "+++\na = 1\n+++\nbody");
2813 assert!(!ed.is_dirty());
2814 }
2815
2816 #[tokio::test]
2817 async fn note_reload_failure_flashes_instead_of_dropping() {
2818 let (mut screen, vault, tx, _rx, _dir) = screen_on_note().await;
2819 vault.delete_note(&VaultPath::new("n.md")).await.unwrap();
2820 screen
2821 .handle_app_message(AppEvent::NoteReloadFromDisk(VaultPath::new("n.md")), &tx)
2822 .await;
2823 assert_eq!(
2824 screen.footer.flash_text(),
2825 Some("reload failed — reopen the note before editing")
2826 );
2827 }
2828
2829 #[tokio::test]
2832 async fn note_reload_keeps_a_dirty_buffer_and_its_autosave() {
2833 let (mut screen, vault, tx, mut rx, _dir) = screen_on_note().await;
2834 screen.handle_input(&chr('x'), &tx);
2835 assert!(screen.panels.editor().unwrap().is_dirty(), "precondition");
2836 screen.autosave_task.spawn(std::future::pending::<()>());
2837 vault
2838 .save_note(&VaultPath::new("n.md"), "+++\na = 1\n+++\nhi")
2839 .await
2840 .unwrap();
2841 screen
2842 .handle_app_message(AppEvent::NoteReloadFromDisk(VaultPath::new("n.md")), &tx)
2843 .await;
2844 let ed = screen.panels.editor().unwrap();
2845 assert_eq!(ed.get_text(), "xhi", "the typing is kept");
2846 assert!(ed.is_dirty(), "still unsaved");
2847 assert!(
2848 screen.autosave_task.is_in_flight(),
2849 "the autosave carrying the typing is not aborted"
2850 );
2851 assert_eq!(
2852 screen.footer.flash_text(),
2853 Some("properties changed on disk — reopen the note to see them")
2854 );
2855 loop {
2858 let evt = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
2859 .await
2860 .expect("the disk count (1) is reported")
2861 .expect("open channel");
2862 if let AppEvent::PropertyCountLoaded { count: 1, .. } = evt {
2863 break;
2864 }
2865 }
2866 assert_eq!(screen.panels.editor().unwrap().get_text(), "xhi");
2867 screen.autosave_task.abort();
2868 }
2869
2870 async fn pump_until(
2873 screen: &mut EditorScreen,
2874 rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
2875 tx: &AppTx,
2876 mut done: impl FnMut(&AppEvent) -> bool,
2877 ) {
2878 let mut finished = false;
2879 while !finished {
2880 let evt = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
2881 .await
2882 .expect("timely event")
2883 .expect("open channel");
2884 finished = done(&evt);
2885 screen.handle_app_message(evt, tx).await;
2886 }
2887 }
2888
2889 #[tokio::test]
2892 async fn properties_dialog_set_reloads_the_editor_buffer() {
2893 use ratatui::crossterm::event::KeyCode;
2894 let (mut screen, vault, tx, mut rx, _dir) = screen_on_note().await;
2895 let path = VaultPath::new("n.md");
2896 screen
2897 .handle_file_op(FileOp::ShowProperties(path.clone()), &tx)
2898 .await;
2899 assert!(screen.overlays.is_open());
2900 pump_until(&mut screen, &mut rx, &tx, |e| {
2901 matches!(
2902 e,
2903 AppEvent::OverlayData(OverlayData::PropertiesLoaded { .. })
2904 )
2905 })
2906 .await;
2907 screen.handle_input(&chr('a'), &tx);
2908 for c in "priority".chars() {
2909 screen.handle_input(&chr(c), &tx);
2910 }
2911 screen.handle_input(&key_event(KeyCode::Tab), &tx);
2912 screen.handle_input(&key_event(KeyCode::Tab), &tx);
2913 screen.handle_input(&chr('2'), &tx);
2914 screen.handle_input(&key_event(KeyCode::Enter), &tx);
2915 let (mut reloaded, mut written) = (false, false);
2916 pump_until(&mut screen, &mut rx, &tx, |e| {
2917 match e {
2918 AppEvent::NoteReloadFromDisk(_) => reloaded = true,
2919 AppEvent::OverlayData(OverlayData::PropertyWritten { result, .. }) => {
2920 assert!(result.is_ok(), "{result:?}");
2921 written = true;
2922 }
2923 _ => {}
2924 }
2925 reloaded && written
2926 })
2927 .await;
2928 let text = vault.get_note_text(&path).await.unwrap();
2929 assert_eq!(
2930 vault.get_property(&path, "priority").await.unwrap(),
2931 Some(Some(kimun_core::note::PropertyValue::Number(2.0)))
2932 );
2933 let ed = screen.panels.editor().unwrap();
2934 assert_eq!(ed.get_text(), text);
2935 assert!(!ed.is_dirty());
2936 }
2937
2938 #[tokio::test]
2939 async fn properties_open_flushes_dirty_buffer() {
2940 let (mut screen, vault, tx, _rx, _dir) = screen_on_note().await;
2941 assert_eq!(screen.panels.focused(), PanelKind::Editor);
2943 screen.handle_input(&chr('x'), &tx);
2944 assert!(
2945 screen.panels.editor().unwrap().is_dirty(),
2946 "precondition: unsaved edit"
2947 );
2948 screen
2949 .handle_file_op(FileOp::ShowProperties(VaultPath::new("n.md")), &tx)
2950 .await;
2951 assert_eq!(
2952 vault.get_note_text(&VaultPath::new("n.md")).await.unwrap(),
2953 "xhi"
2954 );
2955 assert!(screen.overlays.is_open(), "dialog opened after a good save");
2956 }
2957
2958 fn key_event(code: ratatui::crossterm::event::KeyCode) -> InputEvent {
2959 use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
2960 InputEvent::Key(KeyEvent::new(code, KeyModifiers::NONE))
2961 }
2962
2963 fn ctrl_key(c: char) -> InputEvent {
2964 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2965 InputEvent::Key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL))
2966 }
2967
2968 fn chr(c: char) -> InputEvent {
2969 key_event(ratatui::crossterm::event::KeyCode::Char(c))
2970 }
2971
2972 #[tokio::test]
2975 async fn leader_sequence_opens_drawer_view() {
2976 let (mut screen, _, _, _dir) = test_screen().await;
2977 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2978
2979 screen.panels.open_drawer_view(DrawerView::Tags);
2981
2982 screen.handle_input(&ctrl_key('b'), &tx);
2983 assert!(screen.leader.is_pending());
2984 screen.handle_input(&chr('o'), &tx);
2985 screen.handle_input(&chr('f'), &tx);
2986
2987 assert!(!screen.leader.is_pending());
2988 assert_eq!(screen.panels.active_drawer_view(), DrawerView::Files);
2989 assert_eq!(screen.panels.focused(), PanelKind::Drawer);
2990 }
2991
2992 #[tokio::test]
2997 async fn leader_formatting_yields_to_the_find_bar() {
2998 let (mut screen, _, _, _dir) = test_screen().await;
2999 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3000 screen
3001 .panels
3002 .editor_mut()
3003 .unwrap()
3004 .set_text("plain".to_string());
3005 screen.run_op(EditorOp::FindInBuffer, &tx);
3006 assert_eq!(
3007 screen.panels.editor().unwrap().claim(),
3008 crate::components::text_editor::EditorClaim::FindBar
3009 );
3010
3011 screen.handle_input(&ctrl_key('b'), &tx);
3012 screen.handle_input(&chr('t'), &tx);
3013 screen.handle_input(&chr('b'), &tx);
3014
3015 assert!(!screen.leader.is_pending());
3016 let text = screen.panels.editor().unwrap().get_text();
3017 assert!(
3018 !text.contains("**"),
3019 "the note was formatted under a claim: {text:?}"
3020 );
3021 }
3022
3023 #[tokio::test]
3026 async fn leader_formatting_applies_with_the_editor_focused() {
3027 let (mut screen, _, _, _dir) = test_screen().await;
3028 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3029 screen
3030 .panels
3031 .editor_mut()
3032 .unwrap()
3033 .set_text("plain".to_string());
3034
3035 screen.handle_input(&ctrl_key('b'), &tx);
3036 screen.handle_input(&chr('t'), &tx);
3037 screen.handle_input(&chr('b'), &tx);
3038
3039 let text = screen.panels.editor().unwrap().get_text();
3040 assert!(text.contains("**"), "no bold marker was inserted: {text:?}");
3041 }
3042
3043 #[tokio::test]
3046 async fn opening_files_drawer_reveals_current_note_dir() {
3047 let (mut screen, _, _, _dir) = test_screen().await;
3048 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3049
3050 screen.path = VaultPath::new("projects").append(&VaultPath::note_path_from("plan"));
3051 screen
3053 .panels
3054 .sidebar_mut()
3055 .navigate(VaultPath::new("other"), &tx);
3056 screen.panels.hide(PanelKind::Drawer);
3057
3058 screen.open_drawer_view(DrawerView::Files, &tx);
3059
3060 assert!(
3061 screen
3062 .panels
3063 .sidebar()
3064 .current_dir()
3065 .is_like(&VaultPath::new("projects"))
3066 );
3067 }
3068
3069 #[tokio::test]
3073 async fn file_browser_shortcut_rereveals_note_dir_when_already_open() {
3074 let (mut screen, _, _, _dir) = test_screen().await;
3075 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3076
3077 screen.path = VaultPath::new("projects").append(&VaultPath::note_path_from("plan"));
3078 screen.open_drawer_view(DrawerView::Files, &tx);
3079 screen
3081 .panels
3082 .sidebar_mut()
3083 .navigate(VaultPath::new("other"), &tx);
3084
3085 screen.handle_input(&ctrl_key('e'), &tx);
3086
3087 assert!(
3088 screen
3089 .panels
3090 .sidebar()
3091 .current_dir()
3092 .is_like(&VaultPath::new("projects"))
3093 );
3094 }
3095
3096 #[tokio::test]
3099 async fn leader_consumes_keys_while_editor_focused() {
3100 let (mut screen, _, _, _dir) = test_screen().await;
3101 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3102
3103 screen.panels.editor_mut().unwrap().set_text(String::new());
3104 assert_eq!(screen.panels.focused(), PanelKind::Editor);
3105
3106 screen.handle_input(&ctrl_key('b'), &tx);
3107 assert!(screen.leader.is_pending());
3108 screen.handle_input(&chr('w'), &tx);
3110 screen.handle_input(&chr('z'), &tx); assert_eq!(screen.panels.editor().unwrap().get_text(), "");
3112 assert!(!screen.panels.is_visible(PanelKind::Drawer));
3113 }
3114
3115 #[tokio::test]
3117 async fn leader_esc_cancels_and_focuses_editor() {
3118 let (mut screen, _, _, _dir) = test_screen().await;
3119 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3120
3121 screen.panels.focus(PanelKind::Rail);
3122 screen.handle_input(&ctrl_key('b'), &tx);
3123 screen.handle_input(&chr('f'), &tx);
3124 screen.handle_input(&key_event(ratatui::crossterm::event::KeyCode::Esc), &tx);
3125
3126 assert!(!screen.leader.is_pending());
3127 assert_eq!(screen.panels.focused(), PanelKind::Editor);
3128 }
3129
3130 async fn wait_for(
3132 rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
3133 pred: impl Fn(&AppEvent) -> bool,
3134 ) -> AppEvent {
3135 tokio::time::timeout(std::time::Duration::from_secs(2), async {
3136 loop {
3137 let e = rx.recv().await.expect("channel open");
3138 if pred(&e) {
3139 break e;
3140 }
3141 }
3142 })
3143 .await
3144 .expect("expected event")
3145 }
3146
3147 #[tokio::test(flavor = "multi_thread")]
3150 async fn leader_f_p_opens_the_pinned_notes_dialog() {
3151 let (mut screen, _, _, _dir) = test_screen().await;
3152 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3153 screen.handle_input(&ctrl_key('b'), &tx);
3154 screen.handle_input(&chr('f'), &tx);
3155 screen.handle_input(&chr('p'), &tx);
3156 assert!(screen.overlays.is_open());
3157 assert_eq!(
3158 screen.overlays.active_kind(),
3159 Some(crate::components::overlay::OverlayKind::Dialog)
3160 );
3161 }
3162
3163 #[tokio::test(flavor = "multi_thread")]
3166 async fn leader_m_i_toggles_the_pin_and_flashes() {
3167 let (mut screen, vault, _, _dir) = test_screen().await;
3168 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3169 let note = VaultPath::new("plan.md");
3170 vault.create_note(¬e, "hi").await.unwrap();
3171 screen.path = note.clone();
3172
3173 screen.handle_input(&ctrl_key('b'), &tx);
3174 screen.handle_input(&chr('m'), &tx);
3175 screen.handle_input(&chr('i'), &tx);
3176 let e = wait_for(&mut rx, |e| matches!(e, AppEvent::FlashMessage(_))).await;
3177 assert!(
3178 matches!(&e, AppEvent::FlashMessage(m) if m == "pinned as 1 of 9"),
3179 "{e:?}"
3180 );
3181 assert_eq!(
3183 vault.list_pinned_notes().await.unwrap(),
3184 vec![VaultPath::new("/plan.md")]
3185 );
3186
3187 screen.handle_input(&ctrl_key('b'), &tx);
3188 screen.handle_input(&chr('m'), &tx);
3189 screen.handle_input(&chr('i'), &tx);
3190 let e = wait_for(&mut rx, |e| matches!(e, AppEvent::FlashMessage(_))).await;
3191 assert!(
3192 matches!(&e, AppEvent::FlashMessage(m) if m == "unpinned"),
3193 "{e:?}"
3194 );
3195 assert!(vault.list_pinned_notes().await.unwrap().is_empty());
3196 }
3197
3198 #[tokio::test(flavor = "multi_thread")]
3201 async fn leader_m_i_refuses_a_tenth_pin() {
3202 let (mut screen, vault, _, _dir) = test_screen().await;
3203 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3204 for i in 0..kimun_core::PINNED_NOTES_CAP {
3205 let n = VaultPath::new(format!("n{i}.md"));
3206 vault.create_note(&n, "hi").await.unwrap();
3207 vault.toggle_pinned_note(&n).await.unwrap();
3208 }
3209 let extra = VaultPath::new("extra.md");
3210 vault.create_note(&extra, "hi").await.unwrap();
3211 screen.path = extra;
3212 screen.handle_input(&ctrl_key('b'), &tx);
3213 screen.handle_input(&chr('m'), &tx);
3214 screen.handle_input(&chr('i'), &tx);
3215 let e = wait_for(&mut rx, |e| matches!(e, AppEvent::FlashMessage(_))).await;
3216 assert!(
3217 matches!(&e, AppEvent::FlashMessage(m) if m == "already 9 pinned — unpin one first"),
3218 "{e:?}"
3219 );
3220 }
3221
3222 #[tokio::test(flavor = "multi_thread")]
3226 async fn leader_m_i_on_a_missing_note_flashes() {
3227 let (mut screen, vault, _, _dir) = test_screen().await;
3228 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3229 screen.path = VaultPath::new("gone.md");
3230 screen.handle_input(&ctrl_key('b'), &tx);
3231 screen.handle_input(&chr('m'), &tx);
3232 screen.handle_input(&chr('i'), &tx);
3233 let e = wait_for(&mut rx, |e| matches!(e, AppEvent::FlashMessage(_))).await;
3234 assert!(
3235 matches!(&e, AppEvent::FlashMessage(m) if m == "note not found: gone.md"),
3236 "{e:?}"
3237 );
3238 assert!(vault.list_pinned_notes().await.unwrap().is_empty());
3239 }
3240
3241 #[tokio::test(flavor = "multi_thread")]
3245 async fn leader_m_c_with_ask_shown_flashes_like_m_i() {
3246 let (mut screen, vault, _, _dir) = test_screen().await;
3247 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3248 let note = VaultPath::new("plan.md");
3249 vault.create_note(¬e, "hi").await.unwrap();
3250 screen.path = note;
3251 screen.open_drawer_view(DrawerView::Ask, &tx);
3252
3253 screen.handle_input(&ctrl_key('b'), &tx);
3254 screen.handle_input(&chr('m'), &tx);
3255 screen.handle_input(&chr('c'), &tx);
3256 let e = wait_for(&mut rx, |e| matches!(e, AppEvent::FlashMessage(_))).await;
3257 assert!(
3258 matches!(&e, AppEvent::FlashMessage(m) if m == "no note open"),
3259 "{e:?}"
3260 );
3261 }
3262
3263 #[tokio::test(flavor = "multi_thread")]
3266 async fn leader_m_i_with_no_note_open_flashes() {
3267 let (mut screen, _, _, _dir) = test_screen().await;
3268 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3269 screen.handle_input(&ctrl_key('b'), &tx);
3271 screen.handle_input(&chr('m'), &tx);
3272 screen.handle_input(&chr('i'), &tx);
3273 let e = wait_for(&mut rx, |e| matches!(e, AppEvent::FlashMessage(_))).await;
3274 assert!(
3275 matches!(&e, AppEvent::FlashMessage(m) if m == "no note open"),
3276 "{e:?}"
3277 );
3278 }
3279
3280 #[tokio::test(flavor = "multi_thread")]
3283 async fn leader_m_i_with_attachment_shown_flashes() {
3284 let (mut screen, vault, _, _dir) = test_screen().await;
3285 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3286 vault
3287 .save_attachment(&VaultPath::new("assets/diagram.png"), &[1, 2, 3])
3288 .await
3289 .unwrap();
3290 screen
3291 .try_open_attachment(VaultPath::new("assets/diagram.png"), &tx)
3292 .await;
3293 assert!(screen.panels.is_showing_attachment());
3294
3295 screen.handle_input(&ctrl_key('b'), &tx);
3296 screen.handle_input(&chr('m'), &tx);
3297 screen.handle_input(&chr('i'), &tx);
3298 let e = wait_for(&mut rx, |e| matches!(e, AppEvent::FlashMessage(_))).await;
3299 assert!(
3300 matches!(&e, AppEvent::FlashMessage(m) if m == "no note open"),
3301 "{e:?}"
3302 );
3303 }
3304
3305 #[tokio::test(flavor = "multi_thread")]
3308 async fn jumping_from_the_outline_with_an_attachment_shown_flashes() {
3309 use crate::components::drawer_views::HeadingTarget;
3310 let (mut screen, vault, _, _dir) = test_screen().await;
3311 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3312 let note = VaultPath::note_path_from("alpha");
3313 vault.create_note(¬e, "# Alpha\n").await.unwrap();
3314 screen.open_path(note, None, &tx).await;
3315 screen.open_drawer_view(DrawerView::Outline, &tx);
3316 assert_eq!(screen.panels.outline_mut().headings_for_test(), ["Alpha"]);
3317 vault
3318 .save_attachment(&VaultPath::new("assets/diagram.png"), &[1, 2, 3])
3319 .await
3320 .unwrap();
3321 screen
3322 .try_open_attachment(VaultPath::new("assets/diagram.png"), &tx)
3323 .await;
3324
3325 let jump = AppEvent::JumpToHeading(HeadingTarget {
3326 text: "Alpha".to_string(),
3327 occurrence: 0,
3328 count: 1,
3329 line: 0,
3330 });
3331 screen.handle_app_message(jump, &tx).await;
3332 assert_eq!(screen.footer.flash_text(), Some("no note open"));
3333 assert!(screen.panels.outline_mut().headings_for_test().is_empty());
3334 }
3335
3336 #[tokio::test(flavor = "multi_thread")]
3340 async fn leader_m_i_with_ask_shown_flashes() {
3341 let (mut screen, vault, _, _dir) = test_screen().await;
3342 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3343 let note = VaultPath::new("plan.md");
3344 vault.create_note(¬e, "hi").await.unwrap();
3345 screen.path = note;
3346 screen.open_drawer_view(DrawerView::Ask, &tx);
3347 assert!(screen.panels.is_showing_ask());
3348
3349 screen.handle_input(&ctrl_key('b'), &tx);
3350 screen.handle_input(&chr('m'), &tx);
3351 screen.handle_input(&chr('i'), &tx);
3352 let e = wait_for(&mut rx, |e| matches!(e, AppEvent::FlashMessage(_))).await;
3353 assert!(
3354 matches!(&e, AppEvent::FlashMessage(m) if m == "no note open"),
3355 "{e:?}"
3356 );
3357 }
3358
3359 #[tokio::test(flavor = "multi_thread")]
3361 async fn leader_digit_opens_the_nth_pinned_note() {
3362 let (mut screen, vault, _, _dir) = test_screen().await;
3363 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3364 let a = VaultPath::new("a.md");
3365 let b = VaultPath::new("b.md");
3366 vault.create_note(&a, "a").await.unwrap();
3367 vault.create_note(&b, "b").await.unwrap();
3368 vault.toggle_pinned_note(&a).await.unwrap();
3369 vault.toggle_pinned_note(&b).await.unwrap();
3370
3371 screen.handle_input(&ctrl_key('b'), &tx);
3372 screen.handle_input(&chr('2'), &tx);
3373 let e = wait_for(&mut rx, |e| matches!(e, AppEvent::OpenPath { .. })).await;
3374 assert!(
3377 matches!(&e, AppEvent::OpenPath { path, .. } if path.is_like(&b)),
3378 "{e:?}"
3379 );
3380 }
3381
3382 #[tokio::test(flavor = "multi_thread")]
3384 async fn leader_digit_with_no_such_pin_flashes() {
3385 let (mut screen, _, _, _dir) = test_screen().await;
3386 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3387 screen.handle_input(&ctrl_key('b'), &tx);
3388 screen.handle_input(&chr('4'), &tx);
3389 let e = wait_for(&mut rx, |e| matches!(e, AppEvent::FlashMessage(_))).await;
3390 assert!(
3391 matches!(&e, AppEvent::FlashMessage(m) if m == "no pinned note 4"),
3392 "{e:?}"
3393 );
3394 }
3395
3396 #[tokio::test(flavor = "multi_thread")]
3399 async fn leader_digit_to_a_missing_pin_flashes() {
3400 let (mut screen, vault, _, dir) = test_screen().await;
3401 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3402 let gone = VaultPath::new("gone.md");
3403 vault.create_note(&gone, "hi").await.unwrap();
3404 vault.toggle_pinned_note(&gone).await.unwrap();
3405 std::fs::remove_file(dir.path().join("gone.md")).unwrap();
3407 screen.handle_input(&ctrl_key('b'), &tx);
3408 screen.handle_input(&chr('1'), &tx);
3409 let e = wait_for(&mut rx, |e| matches!(e, AppEvent::FlashMessage(_))).await;
3410 assert!(
3412 matches!(&e, AppEvent::FlashMessage(m) if m == "pinned note not found: /gone.md"),
3413 "{e:?}"
3414 );
3415 }
3416
3417 #[tokio::test]
3421 async fn space_never_leads() {
3422 let (mut screen, _, _, _dir) = test_screen().await;
3423 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3424
3425 screen.panels.editor_mut().unwrap().set_text(String::new());
3427 screen.handle_input(&chr(' '), &tx);
3428 assert!(!screen.leader.is_pending());
3429 assert_eq!(screen.panels.editor().unwrap().get_text(), " ");
3430
3431 screen.panels.focus(PanelKind::Rail);
3433 screen.handle_input(&chr(' '), &tx);
3434 assert!(!screen.leader.is_pending());
3435
3436 screen.panels.open_drawer_view(DrawerView::Find);
3438 screen.panels.focus(PanelKind::Drawer);
3439 screen.handle_input(&chr(' '), &tx);
3440 assert!(!screen.leader.is_pending());
3441 }
3442
3443 #[tokio::test]
3444 async fn persist_saved_search_writes_via_core() {
3445 let (screen, _, _, _dir) = test_screen().await;
3446
3447 screen.persist_saved_search("t", "#todo").await.unwrap();
3448
3449 let all = screen.vault.list_saved_searches().await.unwrap();
3450 assert!(all.iter().any(|s| s.name == "t" && s.query == "#todo"));
3451 }
3452
3453 #[tokio::test]
3454 async fn applying_saved_search_sets_panel_query_and_focuses_it() {
3455 let (mut screen, _, _, _dir) = test_screen().await;
3456
3457 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3458 screen.apply_saved_search(
3459 "<{note}".to_string(),
3460 "Backlinks (current note)".to_string(),
3461 &tx,
3462 );
3463 assert!(screen.panels.is_visible(PanelKind::Drawer));
3464 assert_eq!(screen.panels.active_drawer_view(), DrawerView::Find);
3465 assert_eq!(screen.panels.query().active_query(), "<{note}");
3466 assert_eq!(screen.panels.focused(), PanelKind::Drawer);
3467 }
3468
3469 #[tokio::test]
3470 async fn saved_search_persisted_repins_panel_breadcrumb() {
3471 let (mut screen, _, _, _dir) = test_screen().await;
3472
3473 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3474 screen.apply_saved_search("#todo".to_string(), "todo".to_string(), &tx);
3475 screen
3476 .panels
3477 .query_mut()
3478 .set_active_query("#todo and #urgent".to_string());
3479
3480 screen
3481 .handle_app_message(
3482 AppEvent::SavedSearch(SavedSearchFlow::Persisted {
3483 name: "urgent-todos".to_string(),
3484 query: "#todo and #urgent".to_string(),
3485 source: SaveSource::QueryPanel,
3486 }),
3487 &tx,
3488 )
3489 .await;
3490
3491 assert_eq!(
3492 screen.panels.query().saved_search_breadcrumb().as_deref(),
3493 Some("urgent-todos"),
3494 "a persisted panel-sourced save re-pins the breadcrumb"
3495 );
3496 }
3497
3498 #[tokio::test]
3499 async fn persisted_note_browser_save_does_not_repin_even_on_equal_query() {
3500 let (mut screen, _, _, _dir) = test_screen().await;
3501
3502 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3503 screen.apply_saved_search("#todo".to_string(), "todo".to_string(), &tx);
3504
3505 screen
3508 .handle_app_message(
3509 AppEvent::SavedSearch(SavedSearchFlow::Persisted {
3510 name: "inbox".to_string(),
3511 query: "#todo".to_string(),
3512 source: SaveSource::NoteBrowser,
3513 }),
3514 &tx,
3515 )
3516 .await;
3517
3518 assert_eq!(
3519 screen.panels.query().saved_search_breadcrumb().as_deref(),
3520 Some("todo"),
3521 "a note-browser save must not steal the panel's provenance"
3522 );
3523 }
3524
3525 #[tokio::test]
3526 async fn persisted_query_as_name_save_skips_repin() {
3527 let (mut screen, _, _, _dir) = test_screen().await;
3528
3529 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3530 screen.apply_saved_search("#todo".to_string(), "todo".to_string(), &tx);
3531
3532 screen
3536 .handle_app_message(
3537 AppEvent::SavedSearch(SavedSearchFlow::Persisted {
3538 name: "#todo".to_string(),
3539 query: "#todo".to_string(),
3540 source: SaveSource::QueryPanel,
3541 }),
3542 &tx,
3543 )
3544 .await;
3545
3546 assert_eq!(
3547 screen.panels.query().saved_search_breadcrumb().as_deref(),
3548 Some("todo"),
3549 "a query-as-name save leaves the breadcrumb alone"
3550 );
3551 }
3552
3553 #[tokio::test]
3554 async fn save_search_confirmed_persists_then_emits_persisted() {
3555 let (mut screen, vault, _, _dir) = test_screen().await;
3556
3557 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3558 screen
3559 .handle_app_message(
3560 AppEvent::SavedSearch(SavedSearchFlow::Confirmed {
3561 name: "mine".to_string(),
3562 query: "#todo".to_string(),
3563 source: SaveSource::QueryPanel,
3564 }),
3565 &tx,
3566 )
3567 .await;
3568
3569 let event = tokio::time::timeout(std::time::Duration::from_secs(5), async {
3571 loop {
3572 match rx.recv().await {
3573 Some(e @ AppEvent::SavedSearch(SavedSearchFlow::Persisted { .. })) => break e,
3574 Some(_) => continue,
3575 None => panic!("channel closed before SavedSearchPersisted"),
3576 }
3577 }
3578 })
3579 .await
3580 .expect("SavedSearchPersisted within timeout");
3581
3582 let AppEvent::SavedSearch(SavedSearchFlow::Persisted {
3583 name,
3584 query,
3585 source,
3586 }) = event
3587 else {
3588 unreachable!()
3589 };
3590 assert_eq!((name.as_str(), query.as_str()), ("mine", "#todo"));
3591 assert_eq!(source, SaveSource::QueryPanel);
3592 let all = vault.list_saved_searches().await.unwrap();
3593 assert!(all.iter().any(|s| s.name == "mine" && s.query == "#todo"));
3594 }
3595
3596 #[tokio::test]
3597 async fn save_query_source_carries_panel_provenance() {
3598 let (mut screen, _, _, _dir) = test_screen().await;
3599
3600 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3601 screen.apply_saved_search("#todo".to_string(), "todo".to_string(), &tx);
3602
3603 assert_eq!(
3604 screen.save_query_source(),
3605 Some((
3606 "#todo".to_string(),
3607 Some("todo".to_string()),
3608 SaveSource::QueryPanel
3609 )),
3610 "the save dialog opens pre-filled with the breadcrumb provenance"
3611 );
3612 }
3613
3614 #[tokio::test]
3620 async fn saved_search_selected_then_close_overlay_keeps_backlinks_focus() {
3621 use crate::settings::AppSettings;
3622 use kimun_core::VaultConfig;
3623 use std::sync::RwLock;
3624
3625 let dir = tempfile::TempDir::new().unwrap();
3626 let vault = Arc::new(
3627 NoteVault::new(VaultConfig::new(crate::test_support::sys(dir.path())))
3628 .await
3629 .unwrap(),
3630 );
3631 let settings: SharedSettings = Arc::new(RwLock::new(AppSettings::default()));
3632 let mut screen = EditorScreen::new(vault, VaultPath::root(), settings);
3633
3634 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3635 screen
3637 .handle_app_message(
3638 AppEvent::SavedSearch(SavedSearchFlow::Selected {
3639 query: "<{note}".to_string(),
3640 name: "Backlinks (current note)".to_string(),
3641 }),
3642 &tx,
3643 )
3644 .await;
3645 screen.handle_app_message(AppEvent::CloseOverlay, &tx).await;
3646
3647 assert!(
3648 screen.panels.focused() == PanelKind::Drawer
3649 && screen.panels.active_drawer_view() == DrawerView::Find,
3650 "focus should remain on the FIND drawer after select + close"
3651 );
3652 assert!(!screen.overlays.is_open(), "overlay should be closed");
3653 }
3654
3655 #[tokio::test]
3660 async fn opener_action_does_not_replace_open_overlay() {
3661 use crate::settings::AppSettings;
3662 use kimun_core::VaultConfig;
3663 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
3664 use std::sync::RwLock;
3665
3666 let dir = tempfile::TempDir::new().unwrap();
3667 let vault = Arc::new(
3668 NoteVault::new(VaultConfig::new(crate::test_support::sys(dir.path())))
3669 .await
3670 .unwrap(),
3671 );
3672 let settings: SharedSettings = Arc::new(RwLock::new(AppSettings::default()));
3673 let mut screen = EditorScreen::new(vault.clone(), VaultPath::root(), settings.clone());
3674
3675 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3676
3677 {
3680 let s = settings.read().unwrap();
3681 let modal = SavedSearchesModal::new(
3682 vault.clone(),
3683 s.key_bindings.clone(),
3684 s.icons(),
3685 tx.clone(),
3686 );
3687 drop(s);
3688 screen.overlays.open(Box::new(modal), screen.opener_focus());
3689 }
3690 assert_eq!(
3691 screen.overlays.active_kind(),
3692 Some(OverlayKind::SavedSearches),
3693 "precondition: SavedSearches overlay is active"
3694 );
3695
3696 let quick_note_event = KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL);
3700 {
3701 let s = settings.read().unwrap();
3702 let combo = key_event_to_combo(&quick_note_event).expect("Ctrl+W maps to a combo");
3703 assert_eq!(
3704 s.key_bindings.get_action(&combo),
3705 Some(ActionShortcuts::QuickNote),
3706 "test assumes Ctrl+W is bound to QuickNote"
3707 );
3708 }
3709
3710 screen.handle_input(&InputEvent::Key(quick_note_event), &tx);
3711
3712 assert_eq!(
3715 screen.overlays.active_kind(),
3716 Some(OverlayKind::SavedSearches),
3717 "open overlay must not be replaced by a QuickNote opener action"
3718 );
3719 }
3720
3721 #[tokio::test]
3725 async fn focus_action_is_noop_while_overlay_open() {
3726 use crate::settings::AppSettings;
3727 use kimun_core::VaultConfig;
3728 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
3729 use std::sync::RwLock;
3730
3731 let dir = tempfile::TempDir::new().unwrap();
3732 let vault = Arc::new(
3733 NoteVault::new(VaultConfig::new(crate::test_support::sys(dir.path())))
3734 .await
3735 .unwrap(),
3736 );
3737 let settings: SharedSettings = Arc::new(RwLock::new(AppSettings::default()));
3738 let mut screen = EditorScreen::new(vault.clone(), VaultPath::root(), settings.clone());
3739
3740 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3741
3742 {
3744 let s = settings.read().unwrap();
3745 let modal = SavedSearchesModal::new(
3746 vault.clone(),
3747 s.key_bindings.clone(),
3748 s.icons(),
3749 tx.clone(),
3750 );
3751 drop(s);
3752 screen.overlays.open(Box::new(modal), screen.opener_focus());
3753 }
3754 assert_ne!(screen.panels.active_drawer_view(), DrawerView::Find);
3755 let focused_before = screen.panels.focused();
3756
3757 let focus_right = KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL);
3759 {
3760 let s = settings.read().unwrap();
3761 let combo = key_event_to_combo(&focus_right).expect("Ctrl+L maps to a combo");
3762 assert_eq!(
3763 s.key_bindings.get_action(&combo),
3764 Some(ActionShortcuts::FocusEditor),
3765 "test assumes Ctrl+L is bound to FocusEditor"
3766 );
3767 }
3768 screen.handle_input(&InputEvent::Key(focus_right), &tx);
3769
3770 assert_eq!(
3771 screen.panels.focused(),
3772 focused_before,
3773 "focus action must not move focus while an overlay is open"
3774 );
3775 assert_ne!(
3776 screen.panels.active_drawer_view(),
3777 DrawerView::Find,
3778 "focus action must not switch the drawer view while an overlay is open"
3779 );
3780 assert_eq!(
3781 screen.overlays.active_kind(),
3782 Some(OverlayKind::SavedSearches),
3783 "overlay stays active"
3784 );
3785 }
3786
3787 #[tokio::test(flavor = "multi_thread")]
3792 async fn open_journal_dismisses_open_overlay() {
3793 let vault = crate::test_support::temp_vault("editor-journal").await;
3794 vault.validate_and_init().await.unwrap();
3795 let settings = std::sync::Arc::new(std::sync::RwLock::new(
3796 crate::settings::AppSettings::default(),
3797 ));
3798 let mut screen = EditorScreen::new(vault.clone(), VaultPath::root(), settings.clone());
3799 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3800
3801 {
3802 let s = settings.read().unwrap();
3803 let modal = SavedSearchesModal::new(
3804 vault.clone(),
3805 s.key_bindings.clone(),
3806 s.icons(),
3807 tx.clone(),
3808 );
3809 drop(s);
3810 screen.present_overlay(Box::new(modal));
3811 }
3812 assert!(screen.overlays.is_open(), "precondition: overlay open");
3813
3814 let (details, _, _) = vault.journal_entry().await.unwrap();
3815 screen.try_open_path(details.path, None, &tx).await;
3816
3817 assert!(
3818 !screen.overlays.is_open(),
3819 "opening the journal must dismiss the overlay before loading the note"
3820 );
3821 }
3822
3823 #[tokio::test(flavor = "multi_thread")]
3827 async fn opening_attachment_shows_attachment_view() {
3828 let vault = crate::test_support::temp_vault("editor-attachment").await;
3829 vault.validate_and_init().await.unwrap();
3830 vault
3831 .save_attachment(&VaultPath::new("assets/diagram.png"), &[1, 2, 3])
3832 .await
3833 .unwrap();
3834 let settings = std::sync::Arc::new(std::sync::RwLock::new(
3835 crate::settings::AppSettings::default(),
3836 ));
3837 let mut screen = EditorScreen::new(vault.clone(), VaultPath::root(), settings);
3838 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3839
3840 assert!(screen.panels.editor().is_some());
3842
3843 screen
3844 .try_open_attachment(VaultPath::new("assets/diagram.png"), &tx)
3845 .await;
3846
3847 assert!(
3848 screen.panels.is_showing_attachment(),
3849 "the editor area shows the attachment view"
3850 );
3851 assert!(
3852 screen.panels.editor().is_none(),
3853 "no note editor is mounted while an attachment is shown"
3854 );
3855 assert_eq!(
3856 screen.panels.attachment_path(),
3857 Some(&VaultPath::new("assets/diagram.png"))
3858 );
3859
3860 vault
3862 .create_note(&VaultPath::new("note.md"), "hi")
3863 .await
3864 .unwrap();
3865 screen.open_path(VaultPath::new("note.md"), None, &tx).await;
3866 assert!(!screen.panels.is_showing_attachment());
3867 assert!(screen.panels.editor().is_some());
3868 }
3869
3870 #[tokio::test]
3871 async fn save_query_from_note_browser_opens_save_dialog() {
3872 use crate::settings::AppSettings;
3873 use kimun_core::VaultConfig;
3874 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
3875 use std::sync::RwLock;
3876
3877 let dir = tempfile::TempDir::new().unwrap();
3878 let vault = Arc::new(
3879 NoteVault::new(VaultConfig::new(crate::test_support::sys(dir.path())))
3880 .await
3881 .unwrap(),
3882 );
3883 let settings: SharedSettings = Arc::new(RwLock::new(AppSettings::default()));
3884 let mut screen = EditorScreen::new(vault.clone(), VaultPath::root(), settings.clone());
3885
3886 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3887
3888 {
3890 let s = settings.read().unwrap();
3891 let provider = resolving_search_source(vault.clone(), s.current_last_paths(), None);
3892 let modal = NoteBrowserModal::with_initial_query(
3893 "Note Browser",
3894 BrowserScope::Query,
3895 provider,
3896 vault.clone(),
3897 s.key_bindings.clone(),
3898 s.icons(),
3899 tx.clone(),
3900 "#todo",
3901 );
3902 drop(s);
3903 screen.overlays.open(Box::new(modal), screen.opener_focus());
3904 }
3905 assert_eq!(
3906 screen.overlays.active_kind(),
3907 Some(OverlayKind::NoteBrowser),
3908 "precondition: note browser is active with a query"
3909 );
3910 assert_eq!(screen.overlays.active_query(), Some("#todo"));
3911
3912 let save_event = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL);
3915 {
3916 let s = settings.read().unwrap();
3917 let combo = key_event_to_combo(&save_event).expect("Ctrl+D maps to a combo");
3918 assert_eq!(
3919 s.key_bindings.get_action(&combo),
3920 Some(ActionShortcuts::SaveCurrentQuery),
3921 "test assumes Ctrl+D is bound to SaveCurrentQuery"
3922 );
3923 }
3924
3925 screen.handle_input(&InputEvent::Key(save_event), &tx);
3926
3927 assert_eq!(
3928 screen.overlays.active_kind(),
3929 Some(OverlayKind::Dialog),
3930 "saving from the note browser opens the save-search dialog"
3931 );
3932 }
3933
3934 #[tokio::test]
3937 async fn opening_a_missing_note_leaves_the_history_alone() {
3938 use crate::settings::AppSettings;
3939 use std::sync::RwLock;
3940
3941 let vault = crate::test_support::temp_vault("editor-missing").await;
3942 vault
3943 .create_note(&VaultPath::new("here.md"), "text")
3944 .await
3945 .unwrap();
3946 let history_dir = tempfile::TempDir::new().unwrap();
3947 let settings: SharedSettings = Arc::new(RwLock::new(AppSettings::for_test_workspace(
3948 "ws",
3949 vault.workspace_path(),
3950 crate::test_support::sys(history_dir.path()),
3951 )));
3952 let mut screen = EditorScreen::new(vault, VaultPath::root(), settings.clone());
3953 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3954
3955 screen.open_path(VaultPath::new("here.md"), None, &tx).await;
3956 screen
3957 .open_path(VaultPath::new("missing.md"), None, &tx)
3958 .await;
3959
3960 assert_eq!(
3961 settings.read().unwrap().current_last_paths(),
3962 vec![VaultPath::new("here.md")]
3963 );
3964 assert_eq!(
3965 screen.overlays.active_kind(),
3966 Some(OverlayKind::Dialog),
3967 "the create-note offer still stands for an explicit open"
3968 );
3969 }
3970
3971 #[tokio::test(flavor = "multi_thread")]
3974 async fn renaming_open_note_retargets_in_place() {
3975 let vault = crate::test_support::temp_vault("editor-rename").await;
3976 vault.validate_and_init().await.unwrap();
3977 let from = VaultPath::note_path_from("old");
3978 vault.create_note(&from, "# Old\n\nbody").await.unwrap();
3979 let settings = std::sync::Arc::new(std::sync::RwLock::new(
3980 crate::settings::AppSettings::default(),
3981 ));
3982 let mut screen = EditorScreen::new(vault.clone(), from.clone(), settings);
3983 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3984 screen.on_enter(&tx).await;
3985
3986 let to = VaultPath::note_path_from("new");
3987 vault.rename_note(&from, &to).await.unwrap();
3988 screen
3989 .handle_app_message(
3990 AppEvent::FileOp(FileOp::Renamed {
3991 from: from.clone(),
3992 to: to.clone(),
3993 }),
3994 &tx,
3995 )
3996 .await;
3997
3998 assert_eq!(screen.path, to, "editor retargets to the new path");
3999 assert!(
4000 !screen.panels.editor().unwrap().is_dirty(),
4001 "reloaded buffer is clean (won't clobber the renamed file)"
4002 );
4003 }
4004
4005 #[tokio::test(flavor = "multi_thread")]
4009 async fn outline_follows_the_buffer_at_save_and_reveal() {
4010 let vault = crate::test_support::temp_vault("editor-outline-buffer").await;
4011 vault.validate_and_init().await.unwrap();
4012 let path = VaultPath::note_path_from("alpha");
4013 vault.create_note(&path, "# Alpha\n").await.unwrap();
4014 let settings = std::sync::Arc::new(std::sync::RwLock::new(
4015 crate::settings::AppSettings::default(),
4016 ));
4017 let mut screen = EditorScreen::new(vault.clone(), path.clone(), settings);
4018 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4019 screen.on_enter(&tx).await;
4020 let headings = |screen: &mut EditorScreen| screen.panels.outline_mut().headings_for_test();
4021 let edit = |screen: &mut EditorScreen, text: &str| {
4022 let ed = screen.panels.editor_mut().unwrap();
4023 ed.set_text(text.to_string());
4024 ed.content_revision()
4025 };
4026 let saved = |path: &VaultPath, rev| AppEvent::AutosaveCompleted {
4027 path: path.clone(),
4028 saved_revision: rev,
4029 title: rev.map(|_| "Alpha".to_string()),
4030 };
4031
4032 screen.open_drawer_view(DrawerView::Outline, &tx);
4033 assert_eq!(headings(&mut screen), ["Alpha"]);
4034
4035 let rev = edit(&mut screen, "# Alpha\n## Beta\n");
4037 assert_eq!(headings(&mut screen), ["Alpha"]);
4038 screen
4039 .handle_app_message(saved(&path, Some(rev)), &tx)
4040 .await;
4041 assert_eq!(headings(&mut screen), ["Alpha", "Beta"]);
4042
4043 edit(&mut screen, "# Alpha\n## Beta\n## Gamma\n");
4045 screen.handle_app_message(saved(&path, None), &tx).await;
4046 assert_eq!(headings(&mut screen), ["Alpha", "Beta"]);
4047
4048 screen.open_drawer_view(DrawerView::Files, &tx);
4050 screen.open_drawer_view(DrawerView::Outline, &tx);
4051 assert_eq!(headings(&mut screen), ["Alpha", "Beta", "Gamma"]);
4052 }
4053
4054 #[tokio::test(flavor = "multi_thread")]
4057 async fn jumping_to_a_stale_outline_row_refreshes_the_outline() {
4058 use crate::components::drawer_views::HeadingTarget;
4059 let vault = crate::test_support::temp_vault("editor-outline-stale").await;
4060 vault.validate_and_init().await.unwrap();
4061 let path = VaultPath::note_path_from("alpha");
4062 vault.create_note(&path, "# Alpha\n## Foo\n").await.unwrap();
4063 let settings = std::sync::Arc::new(std::sync::RwLock::new(
4064 crate::settings::AppSettings::default(),
4065 ));
4066 let mut screen = EditorScreen::new(vault.clone(), path.clone(), settings);
4067 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4068 screen.on_enter(&tx).await;
4069 screen.open_drawer_view(DrawerView::Outline, &tx);
4070 assert_eq!(
4071 screen.panels.outline_mut().headings_for_test(),
4072 ["Alpha", "Foo"]
4073 );
4074
4075 screen
4076 .panels
4077 .editor_mut()
4078 .unwrap()
4079 .set_text("# Alpha\n## Bar\n".to_string());
4080 let jump = AppEvent::JumpToHeading(HeadingTarget {
4081 text: "Foo".to_string(),
4082 occurrence: 0,
4083 count: 1,
4084 line: 1,
4085 });
4086 screen.handle_app_message(jump, &tx).await;
4087
4088 assert_eq!(screen.panels.editor().unwrap().view_snapshot().cursor.0, 0);
4089 assert!(
4090 screen
4091 .footer
4092 .flash_text()
4093 .is_some_and(|t| t.contains("Foo"))
4094 );
4095 assert_eq!(
4096 screen.panels.outline_mut().headings_for_test(),
4097 ["Alpha", "Bar"]
4098 );
4099
4100 let jump = AppEvent::JumpToHeading(HeadingTarget {
4102 text: "Bar".to_string(),
4103 occurrence: 0,
4104 count: 1,
4105 line: 1,
4106 });
4107 screen.handle_app_message(jump, &tx).await;
4108 assert_eq!(screen.panels.editor().unwrap().view_snapshot().cursor.0, 1);
4109 }
4110
4111 #[tokio::test(flavor = "multi_thread")]
4114 async fn open_then_save_marks_and_retitles_sidebar_row() {
4115 let vault = crate::test_support::temp_vault("editor-marksave").await;
4116 vault.validate_and_init().await.unwrap();
4117 vault
4118 .create_note(&VaultPath::note_path_from("alpha"), "# Alpha\n\nbody")
4119 .await
4120 .unwrap();
4121 let settings = std::sync::Arc::new(std::sync::RwLock::new(
4122 crate::settings::AppSettings::default(),
4123 ));
4124 let path = VaultPath::note_path_from("alpha");
4125 let mut screen = EditorScreen::new(vault.clone(), path.clone(), settings);
4126 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4127 screen.on_enter(&tx).await;
4128 for _ in 0..50 {
4129 screen.panels.sidebar_mut().poll_for_test();
4130 if !screen.panels.sidebar().is_loading_for_test() {
4131 break;
4132 }
4133 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
4134 }
4135
4136 assert!(
4137 screen
4138 .panels
4139 .sidebar()
4140 .note_row_is_open_for_test("alpha.md"),
4141 "the open note's row is marked"
4142 );
4143
4144 screen
4145 .handle_app_message(
4146 AppEvent::AutosaveCompleted {
4147 path: path.clone(),
4148 saved_revision: None,
4149 title: Some("New First Line".to_string()),
4150 },
4151 &tx,
4152 )
4153 .await;
4154
4155 assert_eq!(
4156 screen.panels.sidebar().note_row_title_for_test("alpha.md"),
4157 Some("New First Line".to_string()),
4158 "the saved note's row title updated in place"
4159 );
4160 }
4161
4162 #[tokio::test]
4166 async fn ask_drawer_switch_preserves_thread() {
4167 let (mut screen, _, _, _dir) = test_screen().await;
4168 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4169
4170 screen.open_drawer_view(DrawerView::Ask, &tx);
4172 assert!(screen.panels.is_showing_ask());
4173 {
4174 let panel = screen.panels.ask_mut();
4175 panel.thread_mut().ask("first?".into());
4176 panel.thread_mut().ask("second?".into());
4177 }
4178 assert_eq!(screen.panels.ask().thread().turns().len(), 2);
4179
4180 screen.open_drawer_view(DrawerView::Files, &tx);
4183 assert!(!screen.panels.is_showing_ask());
4184 assert_eq!(screen.panels.ask().thread().turns().len(), 2);
4185
4186 screen.open_drawer_view(DrawerView::Ask, &tx);
4188 assert!(screen.panels.is_showing_ask());
4189 assert_eq!(
4190 screen.panels.ask().thread().turns().len(),
4191 2,
4192 "the conversation survives the round trip"
4193 );
4194 }
4195
4196 #[tokio::test]
4202 async fn ask_answer_ready_reaches_the_thread_via_the_owned_message_path() {
4203 let (mut screen, _, _, _dir) = test_screen().await;
4204 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4205
4206 screen.open_drawer_view(DrawerView::Ask, &tx);
4207 let id = screen.panels.ask_mut().thread_mut().ask("q?".into());
4208
4209 screen
4210 .handle_app_message(
4211 AppEvent::Ask(crate::components::events::AskData::AnswerReady {
4212 turn_id: id,
4213 result: Ok(("the answer".into(), vec![])),
4214 }),
4215 &tx,
4216 )
4217 .await;
4218
4219 let panel = screen.panels.ask_mut();
4220 let turn = panel.thread().selected().expect("a turn is selected");
4221 assert!(matches!(turn.status, crate::ask::TurnStatus::Done));
4222 assert_eq!(turn.answer, "the answer");
4223 }
4224
4225 #[tokio::test]
4230 async fn opening_a_note_over_ask_shows_the_note_and_keeps_the_thread() {
4231 let vault = crate::test_support::temp_vault("editor-ask-open").await;
4232 vault.validate_and_init().await.unwrap();
4233 vault
4234 .create_note(&VaultPath::note_path_from("alpha"), "# Alpha\n\nbody")
4235 .await
4236 .unwrap();
4237 let settings = std::sync::Arc::new(std::sync::RwLock::new(
4238 crate::settings::AppSettings::default(),
4239 ));
4240 let mut screen = EditorScreen::new(vault, VaultPath::root(), settings);
4241 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4242
4243 screen.open_drawer_view(DrawerView::Ask, &tx);
4244 screen
4245 .panels
4246 .ask_mut()
4247 .thread_mut()
4248 .ask("a question?".into());
4249 assert!(screen.panels.is_showing_ask());
4250
4251 screen
4253 .open_path(VaultPath::note_path_from("alpha"), None, &tx)
4254 .await;
4255 assert!(!screen.panels.is_showing_ask(), "the note replaced Ask");
4256 assert!(screen.panels.editor().is_some(), "the note editor is shown");
4257 assert_eq!(
4258 screen.panels.active_drawer_view(),
4259 DrawerView::Files,
4260 "the Sources drawer must not linger on a hidden conversation"
4261 );
4262
4263 screen.open_drawer_view(DrawerView::Ask, &tx);
4265 assert_eq!(
4266 screen.panels.ask().thread().turns().len(),
4267 1,
4268 "the thread survived opening the note"
4269 );
4270 }
4271
4272 #[tokio::test]
4278 async fn opening_a_note_over_ask_with_hidden_drawer_keeps_it_hidden() {
4279 let vault = crate::test_support::temp_vault("editor-ask-hidden-drawer").await;
4280 vault.validate_and_init().await.unwrap();
4281 vault
4282 .create_note(&VaultPath::note_path_from("alpha"), "# Alpha\n\nbody")
4283 .await
4284 .unwrap();
4285 let settings = std::sync::Arc::new(std::sync::RwLock::new(
4286 crate::settings::AppSettings::default(),
4287 ));
4288 let mut screen = EditorScreen::new(vault, VaultPath::root(), settings);
4289 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4290
4291 screen.open_drawer_view(DrawerView::Ask, &tx);
4293 screen.panels.hide(PanelKind::Drawer);
4294 assert!(!screen.panels.is_visible(PanelKind::Drawer));
4295 assert!(screen.panels.is_showing_ask());
4296
4297 screen
4300 .open_path(VaultPath::note_path_from("alpha"), None, &tx)
4301 .await;
4302 assert!(!screen.panels.is_showing_ask(), "the note replaced Ask");
4303 assert!(
4304 !screen.panels.is_visible(PanelKind::Drawer),
4305 "a hidden drawer must not be force-revealed"
4306 );
4307 assert_eq!(
4308 screen.panels.active_drawer_view(),
4309 DrawerView::Files,
4310 "the view switched off Ask to FILES"
4311 );
4312 }
4313
4314 #[tokio::test]
4322 async fn rail_click_reenters_ask_in_one_click_after_a_note_stashed_it() {
4323 let vault = crate::test_support::temp_vault("editor-ask-rail-reentry").await;
4324 vault.validate_and_init().await.unwrap();
4325 vault
4326 .create_note(&VaultPath::note_path_from("alpha"), "# Alpha\n\nbody")
4327 .await
4328 .unwrap();
4329 let settings = std::sync::Arc::new(std::sync::RwLock::new(
4330 crate::settings::AppSettings::default(),
4331 ));
4332 let mut screen = EditorScreen::new(vault, VaultPath::root(), settings);
4333 screen.rag_status = crate::rag::RagStatus::Online {
4334 llm_available: true,
4335 };
4336 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4337
4338 screen
4339 .handle_app_message(AppEvent::OpenDrawerView(DrawerView::Ask), &tx)
4340 .await;
4341 assert!(screen.panels.is_showing_ask());
4342 screen
4343 .panels
4344 .ask_mut()
4345 .thread_mut()
4346 .ask("a question?".into());
4347
4348 screen
4349 .open_path(VaultPath::note_path_from("alpha"), None, &tx)
4350 .await;
4351 assert!(!screen.panels.is_showing_ask());
4352
4353 screen
4355 .handle_app_message(AppEvent::OpenDrawerView(DrawerView::Ask), &tx)
4356 .await;
4357 assert!(
4358 screen.panels.is_showing_ask(),
4359 "a single reopen must not read as a toggle-off"
4360 );
4361 assert_eq!(
4362 screen.panels.ask().thread().turns().len(),
4363 1,
4364 "the thread survived the round trip"
4365 );
4366 }
4367
4368 #[tokio::test]
4374 async fn opening_an_attachment_over_ask_shows_it_and_keeps_the_thread() {
4375 let vault = crate::test_support::temp_vault("editor-ask-open-att").await;
4376 vault.validate_and_init().await.unwrap();
4377 let att_path = VaultPath::new("pic.png");
4378 vault
4379 .save_attachment(&att_path, b"\x89PNG\r\n\x1a\n")
4380 .await
4381 .unwrap();
4382 let settings = std::sync::Arc::new(std::sync::RwLock::new(
4383 crate::settings::AppSettings::default(),
4384 ));
4385 let mut screen = EditorScreen::new(vault, VaultPath::root(), settings);
4386 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4387
4388 screen.open_drawer_view(DrawerView::Ask, &tx);
4389 screen
4390 .panels
4391 .ask_mut()
4392 .thread_mut()
4393 .ask("a question?".into());
4394 assert!(screen.panels.is_showing_ask());
4395
4396 screen.open_attachment(att_path, &tx).await;
4398 assert!(
4399 !screen.panels.is_showing_ask(),
4400 "the attachment replaced Ask"
4401 );
4402
4403 screen.open_drawer_view(DrawerView::Ask, &tx);
4405 assert_eq!(
4406 screen.panels.ask().thread().turns().len(),
4407 1,
4408 "the thread survived opening the attachment"
4409 );
4410 }
4411
4412 #[tokio::test]
4416 async fn refresh_ask_capability_disables_without_a_client() {
4417 let (mut screen, _, _, _dir) = test_screen().await;
4418
4419 screen
4421 .ask
4422 .refresh_capability(&mut screen.panels, screen.rag_status)
4423 .await;
4424 assert!(
4425 !screen.panels.ask().has_client(),
4426 "the resident Ask panel loses its client without an LLM server"
4427 );
4428 }
4429
4430 #[tokio::test]
4433 async fn answer_lands_on_the_thread_while_browsing_files() {
4434 let (mut screen, _, _, _dir) = test_screen().await;
4435 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4436
4437 screen.open_drawer_view(DrawerView::Ask, &tx);
4438 let turn_id = screen.panels.ask_mut().thread_mut().ask("q?".into());
4439 screen.open_drawer_view(DrawerView::Files, &tx);
4441 assert!(!screen.panels.is_showing_ask());
4442
4443 screen.ask.handle_data(
4444 &mut screen.panels,
4445 crate::components::events::AskData::AnswerReady {
4446 turn_id,
4447 result: Ok(("the answer".into(), vec![])),
4448 },
4449 &tx,
4450 );
4451
4452 let thread = screen.panels.ask().thread();
4453 let turn = thread.turns().iter().find(|t| t.id == turn_id).unwrap();
4454 assert!(
4455 matches!(turn.status, crate::ask::TurnStatus::Done),
4456 "the answer completed the turn"
4457 );
4458 }
4459
4460 #[tokio::test]
4467 async fn footer_server_update_replaces_rag_status() {
4468 use crate::server_client::sync::ServerUpdate;
4469 let (mut screen, _, _, _dir) = test_screen().await;
4470 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4471 let footer = |screen: &mut EditorScreen| {
4472 let mut term =
4473 ratatui::Terminal::new(ratatui::backend::TestBackend::new(160, 40)).unwrap();
4474 term.draw(|f| screen.render(f)).unwrap();
4475 let buf = term.backend().buffer();
4476 (0..160u16)
4477 .map(|x| buf[(x, 39)].symbol())
4478 .collect::<String>()
4479 };
4480
4481 screen
4482 .handle_owned_message(
4483 AppEvent::RagStatus(crate::rag::RagStatus::Online {
4484 llm_available: false,
4485 }),
4486 &tx,
4487 )
4488 .await;
4489 screen
4490 .handle_owned_message(
4491 AppEvent::ServerUpdate(Some(ServerUpdate::Newer("9.9.9".into()))),
4492 &tx,
4493 )
4494 .await;
4495 let shown = footer(&mut screen);
4496 assert!(shown.contains("rag: server 9.9.9 available"));
4497 assert!(
4498 !shown.contains("rag: online"),
4499 "status is replaced: {shown}"
4500 );
4501
4502 screen
4503 .handle_owned_message(AppEvent::ServerUpdate(Some(ServerUpdate::Legacy)), &tx)
4504 .await;
4505 assert!(footer(&mut screen).contains("rag: server update available"));
4506
4507 screen
4508 .handle_owned_message(AppEvent::ServerUpdate(None), &tx)
4509 .await;
4510 let cleared = footer(&mut screen);
4511 assert!(cleared.contains("rag: online"), "rag status stays");
4512 assert!(
4513 !cleared.contains("server"),
4514 "the whole hint is gone: {cleared}"
4515 );
4516 }
4517
4518 #[tokio::test]
4519 async fn sem_rail_entry_tracks_search_availability() {
4520 let (mut screen, _, _, _dir) = test_screen().await;
4521 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4522
4523 assert!(!screen.panels.rail_shows(DrawerView::Semantic));
4525 assert!(!screen.panels.rail_shows(DrawerView::Ask));
4526
4527 screen
4529 .handle_owned_message(
4530 AppEvent::RagStatus(crate::rag::RagStatus::Online {
4531 llm_available: false,
4532 }),
4533 &tx,
4534 )
4535 .await;
4536 assert!(screen.panels.rail_shows(DrawerView::Semantic));
4537 assert!(!screen.panels.rail_shows(DrawerView::Ask));
4538
4539 screen
4541 .handle_owned_message(AppEvent::RagStatus(crate::rag::RagStatus::Offline), &tx)
4542 .await;
4543 assert!(!screen.panels.rail_shows(DrawerView::Semantic));
4544
4545 screen
4547 .handle_owned_message(
4548 AppEvent::RagStatus(crate::rag::RagStatus::Online {
4549 llm_available: true,
4550 }),
4551 &tx,
4552 )
4553 .await;
4554 assert!(screen.panels.rail_shows(DrawerView::Semantic));
4555 assert!(screen.panels.rail_shows(DrawerView::Ask));
4556 }
4557
4558 #[tokio::test]
4564 async fn ask_focus_skips_the_llm_gate_when_already_showing_ask() {
4565 let (mut screen, _, _, _dir) = test_screen().await;
4566 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4567
4568 screen.rag_status = crate::rag::RagStatus::Online {
4569 llm_available: true,
4570 };
4571 screen.open_ask_workspace(&tx);
4572 assert!(screen.panels.is_showing_ask());
4573
4574 screen.rag_status = crate::rag::RagStatus::Offline;
4577
4578 screen.open_ask_workspace(&tx);
4579 assert!(screen.panels.is_showing_ask(), "still showing Ask");
4580 assert_eq!(screen.panels.focused(), PanelKind::Editor);
4581 assert!(
4582 rx.try_recv().is_err(),
4583 "no gate flash while Ask is already showing"
4584 );
4585 }
4586
4587 fn lay_out(screen: &mut EditorScreen) {
4599 let mut term = ratatui::Terminal::new(ratatui::backend::TestBackend::new(120, 40)).unwrap();
4600 term.draw(|f| screen.render(f)).unwrap();
4601 }
4602
4603 fn buffer_origin(screen: &EditorScreen) -> (u16, u16) {
4606 (0..40u16)
4607 .flat_map(|row| (0..120u16).map(move |col| (col, row)))
4608 .find(|(col, row)| screen.panels.is_note_buffer_cell(*col, *row))
4609 .expect("a laid-out screen always shows the note buffer")
4610 }
4611
4612 fn frame_corner(screen: &mut EditorScreen, tx: &AppTx) -> (u16, u16) {
4623 let (col, row) = buffer_origin(screen);
4624 let corner = (col - 1, row - 1);
4625 screen.panels.focus(PanelKind::Drawer);
4626 screen.handle_input(&press_at(corner.0, corner.1), tx);
4627 assert_eq!(
4628 screen.panels.focused(),
4629 PanelKind::Editor,
4630 "precondition: the frame is inside the editor column"
4631 );
4632 screen.clicks.end();
4634 corner
4635 }
4636
4637 use crate::test_support::mouse_down_at as press_at;
4638
4639 #[tokio::test]
4640 async fn clicking_props_segment_opens_properties() {
4641 let (mut screen, _vault, tx, mut rx, _dir) = screen_on_note().await;
4642 screen
4644 .handle_app_message(
4645 AppEvent::PropertyCountLoaded {
4646 path: VaultPath::new("n.md"),
4647 count: 0,
4648 },
4649 &tx,
4650 )
4651 .await;
4652 lay_out(&mut screen);
4653 let (col, row) = (0..40u16)
4654 .flat_map(|r| (0..120u16).map(move |c| (c, r)))
4655 .find(|(c, r)| screen.footer.target_at(*c, *r) == Some(FooterTarget::Props))
4656 .expect("props segment laid out");
4657 while rx.try_recv().is_ok() {}
4658 screen.handle_input(&press_at(col, row), &tx);
4659 let mut opened = false;
4660 while let Ok(evt) = rx.try_recv() {
4661 opened |= matches!(evt, AppEvent::FileOp(FileOp::ShowProperties(_)));
4662 }
4663 assert!(opened);
4664 }
4665
4666 #[tokio::test]
4669 async fn clicking_update_segment_opens_update_dialog() {
4670 let (mut screen, _vault, tx, mut rx, _dir) = screen_on_note().await;
4671 screen.update = Some(crate::update::UpdateStatus {
4672 current: "0.1.0".into(),
4673 latest: "9.9.9".into(),
4674 channel: crate::update::InstallChannel::Cargo,
4675 update_available: true,
4676 dismissed: false,
4677 });
4678 lay_out(&mut screen);
4679 let (col, row) = (0..40u16)
4680 .flat_map(|r| (0..120u16).map(move |c| (c, r)))
4681 .find(|(c, r)| screen.footer.target_at(*c, *r) == Some(FooterTarget::Update))
4682 .expect("update segment laid out");
4683 while rx.try_recv().is_ok() {}
4684 screen.handle_input(&press_at(col, row), &tx);
4685 let mut shown = false;
4686 while let Ok(evt) = rx.try_recv() {
4687 shown |= matches!(evt, AppEvent::Update(UpdateFlow::ShowDialog));
4688 }
4689 assert!(shown);
4690 }
4691
4692 #[tokio::test]
4694 async fn clicking_server_update_segment_opens_dialog() {
4695 let (mut screen, _vault, tx, _rx, _dir) = screen_on_note().await;
4696 screen
4697 .handle_app_message(
4698 AppEvent::RagStatus(crate::rag::RagStatus::Online {
4699 llm_available: false,
4700 }),
4701 &tx,
4702 )
4703 .await;
4704 screen
4705 .handle_app_message(
4706 AppEvent::ServerUpdate(Some(crate::server_client::sync::ServerUpdate::Legacy)),
4707 &tx,
4708 )
4709 .await;
4710 lay_out(&mut screen);
4711 let (col, row) = (0..40u16)
4712 .flat_map(|r| (0..120u16).map(move |c| (c, r)))
4713 .find(|(c, r)| screen.footer.target_at(*c, *r) == Some(FooterTarget::ServerUpdate))
4714 .expect("server update segment laid out");
4715 screen.handle_input(&press_at(col, row), &tx);
4716 assert!(screen.overlays.is_open(), "dialog presented");
4717 }
4718
4719 #[tokio::test]
4721 async fn clicking_backlinks_segment_opens_links() {
4722 let (mut screen, _vault, tx, _rx, _dir) = screen_on_note().await;
4723 screen
4724 .handle_app_message(
4725 AppEvent::BacklinkCountLoaded {
4726 path: VaultPath::new("n.md"),
4727 count: 2,
4728 },
4729 &tx,
4730 )
4731 .await;
4732 lay_out(&mut screen);
4733 let (col, row) = (0..40u16)
4734 .flat_map(|r| (0..120u16).map(move |c| (c, r)))
4735 .find(|(c, r)| screen.footer.target_at(*c, *r) == Some(FooterTarget::Backlinks))
4736 .expect("backlinks segment laid out");
4737 screen.handle_input(&press_at(col, row), &tx);
4738 assert_eq!(screen.panels.active_drawer_view(), DrawerView::Links);
4739 }
4740
4741 #[tokio::test]
4744 async fn find_bar_does_not_swallow_footer_clicks() {
4745 let (mut screen, _vault, tx, mut rx, _dir) = screen_on_note().await;
4746 screen
4747 .handle_app_message(
4748 AppEvent::PropertyCountLoaded {
4749 path: VaultPath::new("n.md"),
4750 count: 0,
4751 },
4752 &tx,
4753 )
4754 .await;
4755 screen.run_op(EditorOp::FindInBuffer, &tx);
4756 assert_eq!(
4757 screen.panels.editor().map(|e| e.claim()),
4758 Some(crate::components::text_editor::EditorClaim::FindBar)
4759 );
4760 lay_out(&mut screen);
4761 let (col, row) = (0..40u16)
4762 .flat_map(|r| (0..120u16).map(move |c| (c, r)))
4763 .find(|(c, r)| screen.footer.target_at(*c, *r) == Some(FooterTarget::Props))
4764 .expect("props segment laid out");
4765 while rx.try_recv().is_ok() {}
4766 screen.handle_input(&press_at(col, row), &tx);
4767 let mut opened = false;
4768 while let Ok(evt) = rx.try_recv() {
4769 opened |= matches!(evt, AppEvent::FileOp(FileOp::ShowProperties(_)));
4770 }
4771 assert!(opened);
4772 }
4773
4774 use crate::test_support::mouse_moved_at as moved_at;
4775
4776 fn at(millis: u64) -> std::time::Instant {
4778 static ORIGIN: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
4779 *ORIGIN.get_or_init(std::time::Instant::now) + std::time::Duration::from_millis(millis)
4780 }
4781
4782 #[tokio::test]
4783 async fn two_presses_on_the_buffer_are_a_double_click() {
4784 let (mut screen, _, _, _dir) = test_screen().await;
4785 lay_out(&mut screen);
4786 let (col, row) = buffer_origin(&screen);
4787
4788 assert!(!screen.track_click(&press_at(col, row), at(0)));
4789 assert!(screen.track_click(&press_at(col, row), at(120)));
4790 }
4791
4792 #[tokio::test]
4798 async fn a_press_on_the_editor_frame_is_never_half_of_a_double() {
4799 let (mut screen, _, _, _dir) = test_screen().await;
4800 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4801 lay_out(&mut screen);
4802 let (col, row) = frame_corner(&mut screen, &tx);
4803
4804 assert!(!screen.track_click(&press_at(col, row), at(0)));
4805 assert!(
4806 !screen.track_click(&press_at(col, row), at(120)),
4807 "two clicks on the frame are not a follow"
4808 );
4809 }
4810
4811 #[tokio::test]
4815 async fn a_frame_press_separates_two_buffer_presses() {
4816 let (mut screen, _, _, _dir) = test_screen().await;
4817 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4818 lay_out(&mut screen);
4819 let (frame_col, frame_row) = frame_corner(&mut screen, &tx);
4820 let (col, row) = buffer_origin(&screen);
4821
4822 screen.track_click(&press_at(col, row), at(0));
4823 screen.track_click(&press_at(frame_col, frame_row), at(40));
4824 assert!(!screen.track_click(&press_at(col, row), at(80)));
4825 }
4826
4827 #[tokio::test]
4831 async fn motion_over_the_frame_does_not_break_a_double() {
4832 let (mut screen, _, _, _dir) = test_screen().await;
4833 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4834 lay_out(&mut screen);
4835 let (frame_col, frame_row) = frame_corner(&mut screen, &tx);
4836 let (col, row) = buffer_origin(&screen);
4837
4838 screen.track_click(&press_at(col, row), at(0));
4839 assert!(!screen.track_click(&moved_at(frame_col, frame_row), at(40)));
4840 assert!(screen.track_click(&press_at(col, row), at(120)));
4841 }
4842
4843 #[tokio::test]
4844 async fn a_key_between_presses_ends_the_click_run() {
4845 let (mut screen, _, _, _dir) = test_screen().await;
4846 lay_out(&mut screen);
4847 let (col, row) = buffer_origin(&screen);
4848
4849 screen.track_click(&press_at(col, row), at(0));
4850 assert!(!screen.track_click(&chr('x'), at(40)));
4851 assert!(!screen.track_click(&press_at(col, row), at(80)));
4852 }
4853
4854 #[tokio::test]
4859 async fn an_overlay_ends_the_click_run() {
4860 let (mut screen, _, _, _dir) = test_screen().await;
4861 lay_out(&mut screen);
4862 let (col, row) = buffer_origin(&screen);
4863 let vault = screen.vault.clone();
4864
4865 screen.track_click(&press_at(col, row), at(0));
4866 screen.present_overlay(Box::new(ActiveDialog::create_note(
4867 VaultPath::note_path_from("x"),
4868 vault,
4869 None,
4870 )));
4871 assert!(
4872 !screen.track_click(&press_at(col, row), at(40)),
4873 "a press under an overlay is the overlay's"
4874 );
4875 screen.dismiss_overlay();
4876 assert!(
4877 !screen.track_click(&press_at(col, row), at(80)),
4878 "and neither press survives to pair with the next one"
4879 );
4880 }
4881
4882 #[tokio::test]
4886 async fn a_double_click_on_a_wikilink_follows_it() {
4887 let (mut screen, _, _, _dir) = test_screen().await;
4888 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4889 screen
4890 .panels
4891 .editor_mut()
4892 .unwrap()
4893 .set_text("[[target note]]".to_string());
4894 lay_out(&mut screen);
4895 let (col, row) = buffer_origin(&screen);
4898 let (col, row) = (col + 6, row);
4899
4900 screen.handle_input(&press_at(col, row), &tx);
4901 screen.handle_input(&press_at(col, row), &tx);
4902
4903 let followed = crate::test_support::drain(&mut rx)
4904 .into_iter()
4905 .find_map(|ev| match ev {
4906 AppEvent::FollowLink(target) => Some(target),
4907 _ => None,
4908 })
4909 .expect("the double-click emitted no FollowLink");
4910 assert_eq!(followed, "target note");
4911 }
4912
4913 #[tokio::test]
4916 async fn a_single_press_on_a_wikilink_only_places_the_cursor() {
4917 let (mut screen, _, _, _dir) = test_screen().await;
4918 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4919 screen
4920 .panels
4921 .editor_mut()
4922 .unwrap()
4923 .set_text("[[target note]]".to_string());
4924 lay_out(&mut screen);
4925 let (col, row) = buffer_origin(&screen);
4926
4927 screen.handle_input(&press_at(col + 6, row), &tx);
4928
4929 assert!(
4930 !crate::test_support::drain(&mut rx)
4931 .into_iter()
4932 .any(|ev| matches!(ev, AppEvent::FollowLink(_))),
4933 "one click is a cursor placement, not a navigation"
4934 );
4935 assert!(
4936 screen
4937 .panels
4938 .editor()
4939 .unwrap()
4940 .follow_target_at_cursor()
4941 .is_some(),
4942 "but it did land on the link, so the next press has something to follow"
4943 );
4944 }
4945}
4946
4947#[cfg(test)]
4948mod sort_routing_tests {
4949 use super::*;
4950 use crate::app_screen::AppScreen;
4951 use crate::components::events::SortTarget;
4952 use crate::components::file_list::SortOrder;
4953 use crate::components::sortable::SortState;
4954
4955 async fn make_editor() -> (
4956 EditorScreen,
4957 AppTx,
4958 tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
4959 ) {
4960 let vault = crate::test_support::temp_vault("editor-sort").await;
4961 vault.validate_and_init().await.unwrap();
4962 let settings = std::sync::Arc::new(std::sync::RwLock::new(
4963 crate::settings::AppSettings::default(),
4964 ));
4965 let screen = EditorScreen::new(vault, VaultPath::root(), settings);
4966 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
4967 (screen, tx, rx)
4968 }
4969
4970 fn find_drawn(screen: &mut EditorScreen, text: &str) -> Option<(u16, u16)> {
4973 let mut term = ratatui::Terminal::new(ratatui::backend::TestBackend::new(120, 40)).unwrap();
4974 term.draw(|f| screen.render(f)).unwrap();
4975 crate::test_support::find_text(term.backend().buffer(), text)
4976 }
4977
4978 #[tokio::test(flavor = "multi_thread")]
4981 async fn clicking_the_sort_chip_opens_the_sort_dialog() {
4982 let (mut screen, tx, mut rx) = make_editor().await;
4983 screen.panels.open_drawer_view(DrawerView::Files);
4984 let (col, row) = find_drawn(&mut screen, "Name ↑").expect("FILES draws its sort chip");
4985 let (_, search_row) = find_drawn(&mut screen, " Search").expect("search box drawn");
4986 assert_eq!(
4987 row, search_row,
4988 "the chip sits on the search box, not the header"
4989 );
4990 while rx.try_recv().is_ok() {}
4991 screen.handle_input(&crate::test_support::mouse_down_at(col, row), &tx);
4992 let mut target = None;
4993 while let Ok(e) = rx.try_recv() {
4994 if let AppEvent::OpenSortDialog(t) = e {
4995 target = Some(t);
4996 }
4997 }
4998 assert_eq!(target, Some(SortTarget::Sidebar));
4999 screen
5000 .handle_app_message(AppEvent::OpenSortDialog(SortTarget::Sidebar), &tx)
5001 .await;
5002 assert_eq!(screen.overlays.active_kind(), Some(OverlayKind::Dialog));
5003 }
5004
5005 fn drawn_buffer(screen: &mut EditorScreen) -> ratatui::buffer::Buffer {
5006 let mut term = ratatui::Terminal::new(ratatui::backend::TestBackend::new(120, 40)).unwrap();
5007 term.draw(|f| screen.render(f)).unwrap();
5008 term.backend().buffer().clone()
5009 }
5010
5011 use crate::test_support::mouse_moved_at as moved_to;
5012
5013 #[tokio::test(flavor = "multi_thread")]
5016 async fn hovering_a_click_target_highlights_it() {
5017 let (mut screen, tx, _rx) = make_editor().await;
5018 let hover = screen.theme.hover().bg.unwrap();
5019 let (col, row) = find_drawn(&mut screen, "≡ Commands").unwrap();
5020 screen.handle_input(&moved_to(col + 2, row), &tx);
5021 let buf = drawn_buffer(&mut screen);
5022 assert_eq!(buf[(col, row)].bg, hover, "the whole target lights up");
5023 assert_eq!(buf[(col + 9, row)].bg, hover);
5024 assert_ne!(
5025 buf[(col.saturating_sub(2), row)].bg,
5026 hover,
5027 "only the target"
5028 );
5029
5030 screen.handle_input(&moved_to(0, 20), &tx);
5031 let buf = drawn_buffer(&mut screen);
5032 assert_ne!(buf[(col, row)].bg, hover, "hover follows the pointer");
5033 }
5034
5035 #[tokio::test(flavor = "multi_thread")]
5038 async fn an_overlay_hides_hover_on_what_it_covers() {
5039 let (mut screen, tx, _rx) = make_editor().await;
5040 let hover = screen.theme.hover().bg.unwrap();
5041 let (col, row) = find_drawn(&mut screen, "≡ Commands").unwrap();
5042 screen.open_overlay(OverlayOpen::Cheatsheet, &tx);
5043 screen.handle_input(&moved_to(col + 2, row), &tx);
5044 let buf = drawn_buffer(&mut screen);
5045 assert_ne!(buf[(col, row)].bg, hover, "panels are inert under a modal");
5046
5047 let (ccol, crow) = find_drawn(&mut screen, "[Esc] Close").unwrap();
5048 screen.handle_input(&moved_to(ccol + 1, crow), &tx);
5050 let buf = drawn_buffer(&mut screen);
5051 assert_eq!(buf[(ccol, crow)].bg, hover, "the dialog's chip lights up");
5052 }
5053
5054 #[tokio::test(flavor = "multi_thread")]
5057 async fn sort_chip_stays_clickable_beside_a_parse_error() {
5058 let (mut screen, tx, mut rx) = make_editor().await;
5059 screen.panels.open_drawer_view(DrawerView::Find);
5060 screen
5061 .handle_app_message(AppEvent::RunTagQuery("x".into()), &tx)
5062 .await;
5063 screen
5064 .panels
5065 .query_mut()
5066 .set_active_query(r#"#x "open"#.to_string());
5067 assert!(
5068 find_drawn(&mut screen, "⚠").is_some(),
5069 "error segment drawn"
5070 );
5071 let (col, row) = find_drawn(&mut screen, "Name ↑").expect("chip still drawn");
5072 let (ecol, erow) = find_drawn(&mut screen, "⚠").unwrap();
5073 assert_eq!(row, erow);
5074 assert!(col > ecol, "the chip is the rightmost segment");
5075 while rx.try_recv().is_ok() {}
5076 screen.handle_input(&crate::test_support::mouse_down_at(col, row), &tx);
5077 assert!(
5078 crate::test_support::drain(&mut rx)
5079 .into_iter()
5080 .any(|e| matches!(e, AppEvent::OpenSortDialog(SortTarget::Query)))
5081 );
5082 }
5083
5084 #[tokio::test(flavor = "multi_thread")]
5087 async fn a_narrow_title_bar_keeps_room_for_the_path() {
5088 let (mut screen, _tx, _rx) = make_editor().await;
5089 screen.settings.write().unwrap().workspace_config =
5090 Some(crate::settings::workspace_config::WorkspaceConfig::new_empty());
5091 let mut term = ratatui::Terminal::new(ratatui::backend::TestBackend::new(24, 20)).unwrap();
5092 term.draw(|f| screen.render(f)).unwrap();
5093 let row: String = (0..24u16)
5094 .map(|x| term.backend().buffer()[(x, 0)].symbol().to_string())
5095 .collect();
5096 assert!(
5097 row.contains("Kimün"),
5098 "the left side keeps its room: {row:?}"
5099 );
5100 assert!(row.contains('≡'), "the palette door stays: {row:?}");
5101 assert!(
5102 !row.contains("Commands"),
5103 "but shrinks to the glyph: {row:?}"
5104 );
5105 assert!(
5106 screen
5107 .title_targets
5108 .iter()
5109 .any(|(_, t)| *t == TitleTarget::Palette)
5110 );
5111 }
5112
5113 use crate::test_support::drain as events;
5114
5115 #[tokio::test(flavor = "multi_thread")]
5118 async fn the_search_browser_sort_label_opens_a_stacked_sort_dialog() {
5119 let (mut screen, tx, mut rx) = make_editor().await;
5120 screen.open_overlay(OverlayOpen::SearchBrowser, &tx);
5121 let (col, row) = find_drawn(&mut screen, "Unsorted").expect("browser shows its sort");
5122 events(&mut rx);
5123 screen.handle_input(&crate::test_support::mouse_down_at(col, row), &tx);
5124 let sent = events(&mut rx);
5125 assert!(
5126 sent.iter()
5127 .any(|e| matches!(e, AppEvent::OpenSortDialog(SortTarget::Browser)))
5128 );
5129 screen
5130 .handle_app_message(AppEvent::OpenSortDialog(SortTarget::Browser), &tx)
5131 .await;
5132 assert_eq!(screen.overlays.active_kind(), Some(OverlayKind::Dialog));
5133 assert_eq!(
5134 screen.overlays.parked_kind(),
5135 Some(OverlayKind::NoteBrowser),
5136 "stacked over the browser, not replacing it"
5137 );
5138 }
5139
5140 #[tokio::test(flavor = "multi_thread")]
5143 async fn the_search_browser_opens_query_syntax_over_itself() {
5144 use ratatui::crossterm::event::KeyCode;
5145 let (mut screen, tx, mut rx) = make_editor().await;
5146 screen.open_overlay(OverlayOpen::SearchBrowser, &tx);
5147 let (col, row) = find_drawn(&mut screen, "[F1] Query syntax").expect("chip drawn");
5148 events(&mut rx);
5149 screen.handle_input(&crate::test_support::mouse_down_at(col, row), &tx);
5150 assert!(
5151 events(&mut rx)
5152 .iter()
5153 .any(|e| matches!(e, AppEvent::OpenQueryHelp))
5154 );
5155 screen
5156 .handle_app_message(AppEvent::OpenQueryHelp, &tx)
5157 .await;
5158 assert_eq!(screen.overlays.active_kind(), Some(OverlayKind::Dialog));
5159 assert_eq!(
5160 screen.overlays.parked_kind(),
5161 Some(OverlayKind::NoteBrowser)
5162 );
5163
5164 screen.overlays.close();
5166 assert_eq!(
5167 screen.overlays.active_kind(),
5168 Some(OverlayKind::NoteBrowser)
5169 );
5170 screen.handle_input(&key(KeyCode::F(1), false), &tx);
5171 assert_eq!(
5172 screen.overlays.parked_kind(),
5173 Some(OverlayKind::NoteBrowser)
5174 );
5175 }
5176
5177 #[tokio::test(flavor = "multi_thread")]
5179 async fn the_file_finder_has_no_syntax_chip() {
5180 let (mut screen, tx, _rx) = make_editor().await;
5181 screen.open_overlay(OverlayOpen::FileFinder, &tx);
5182 assert!(find_drawn(&mut screen, "Query syntax").is_none());
5183 assert!(find_drawn(&mut screen, "[Esc] Close").is_some());
5184 }
5185
5186 #[tokio::test(flavor = "multi_thread")]
5187 async fn the_find_syntax_chip_opens_the_reference() {
5188 let (mut screen, tx, mut rx) = make_editor().await;
5189 screen.panels.open_drawer_view(DrawerView::Find);
5190 let (col, row) = find_drawn(&mut screen, "[F1] Syntax").expect("chip drawn");
5191 events(&mut rx);
5192 screen.handle_input(&crate::test_support::mouse_down_at(col, row), &tx);
5193 assert!(
5194 events(&mut rx)
5195 .iter()
5196 .any(|e| matches!(e, AppEvent::OpenQueryHelp))
5197 );
5198 screen
5199 .handle_app_message(AppEvent::OpenQueryHelp, &tx)
5200 .await;
5201 assert_eq!(screen.overlays.active_kind(), Some(OverlayKind::Dialog));
5202 }
5203
5204 #[tokio::test(flavor = "multi_thread")]
5207 async fn title_bar_targets_open_palette_and_switcher() {
5208 let (mut screen, tx, _rx) = make_editor().await;
5209 screen.settings.write().unwrap().workspace_config =
5210 Some(crate::settings::workspace_config::WorkspaceConfig::new_empty());
5211 let (col, row) = find_drawn(&mut screen, "≡ Commands").expect("palette button drawn");
5212 screen.handle_input(&crate::test_support::mouse_down_at(col, row), &tx);
5213 assert_eq!(
5214 screen.overlays.active_kind(),
5215 Some(OverlayKind::CommandPalette)
5216 );
5217 screen.overlays.close_all();
5218
5219 find_drawn(&mut screen, "≡");
5220 let (col, row) = (0..120u16)
5221 .map(|c| (c, 0u16))
5222 .find(|&(c, r)| screen.title_target_at(c, r) == Some(TitleTarget::Workspace))
5223 .expect("workspace badge is a target");
5224 screen.handle_input(&crate::test_support::mouse_down_at(col, row), &tx);
5225 assert_eq!(screen.overlays.active_kind(), Some(OverlayKind::Dialog));
5226 }
5227
5228 #[tokio::test(flavor = "multi_thread")]
5229 async fn find_shows_a_sort_chip_only_while_querying() {
5230 let (mut screen, tx, mut rx) = make_editor().await;
5231 screen.panels.open_drawer_view(DrawerView::Find);
5232 assert!(
5233 find_drawn(&mut screen, "Name ↑").is_none(),
5234 "nothing to sort yet"
5235 );
5236 screen
5237 .handle_app_message(AppEvent::RunTagQuery("x".into()), &tx)
5238 .await;
5239 let (col, row) = find_drawn(&mut screen, "Name ↑").expect("a running query is sortable");
5240 let (_, query_row) = find_drawn(&mut screen, " Query").expect("query box drawn");
5241 assert_eq!(row, query_row, "the chip sits on the query box");
5242 while rx.try_recv().is_ok() {}
5243 screen.handle_input(&crate::test_support::mouse_down_at(col, row), &tx);
5244 assert!(
5245 crate::test_support::drain(&mut rx)
5246 .into_iter()
5247 .any(|e| matches!(e, AppEvent::OpenSortDialog(SortTarget::Query)))
5248 );
5249 }
5250
5251 #[tokio::test(flavor = "multi_thread")]
5252 async fn sort_save_default_persists_to_settings() {
5253 let (mut screen, tx, _rx) = make_editor().await;
5254 screen
5255 .handle_app_message(
5256 AppEvent::SortChanged {
5257 target: SortTarget::Sidebar,
5258 state: SortState {
5259 field: SortField::Title,
5260 order: SortOrder::Descending,
5261 group_dirs: Some(true),
5262 },
5263 persist: true,
5264 },
5265 &tx,
5266 )
5267 .await;
5268 let s = screen.settings.read().unwrap();
5269 assert_eq!(
5270 s.default_sort_field,
5271 crate::settings::SortFieldSetting::Title
5272 );
5273 assert_eq!(
5274 s.default_sort_order,
5275 crate::settings::SortOrderSetting::Descending
5276 );
5277 assert!(s.group_directories);
5278 }
5279
5280 #[tokio::test(flavor = "multi_thread")]
5281 async fn sort_save_default_journal_dir_writes_journal_settings() {
5282 let (mut screen, tx, _rx) = make_editor().await;
5283 let journal = screen.vault.journal_path().clone();
5285 screen.panels.sidebar_mut().navigate(journal, &tx);
5286
5287 screen
5288 .handle_app_message(
5289 AppEvent::SortChanged {
5290 target: SortTarget::Sidebar,
5291 state: SortState {
5292 field: SortField::Title,
5293 order: SortOrder::Ascending,
5294 group_dirs: Some(false),
5295 },
5296 persist: true,
5297 },
5298 &tx,
5299 )
5300 .await;
5301
5302 let s = screen.settings.read().unwrap();
5303 assert_eq!(
5304 s.journal_sort_field,
5305 crate::settings::SortFieldSetting::Title
5306 );
5307 assert_eq!(
5308 s.journal_sort_order,
5309 crate::settings::SortOrderSetting::Ascending
5310 );
5311 assert_eq!(
5313 s.default_sort_field,
5314 crate::settings::SortFieldSetting::Name
5315 );
5316 }
5317
5318 #[tokio::test(flavor = "multi_thread")]
5321 async fn sort_changed_without_persist_leaves_settings() {
5322 let (mut screen, tx, _rx) = make_editor().await;
5323 screen
5324 .handle_app_message(
5325 AppEvent::SortChanged {
5326 target: SortTarget::Sidebar,
5327 state: SortState {
5328 field: SortField::Title,
5329 order: SortOrder::Descending,
5330 group_dirs: Some(true),
5331 },
5332 persist: false,
5333 },
5334 &tx,
5335 )
5336 .await;
5337 let s = screen.settings.read().unwrap();
5338 assert_eq!(
5339 s.default_sort_field,
5340 crate::settings::SortFieldSetting::Name
5341 );
5342 assert_eq!(
5343 s.default_sort_order,
5344 crate::settings::SortOrderSetting::Ascending
5345 );
5346 assert!(!s.group_directories);
5347 }
5348
5349 fn key(code: ratatui::crossterm::event::KeyCode, ctrl: bool) -> InputEvent {
5350 use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
5351 let mods = if ctrl {
5352 KeyModifiers::CONTROL
5353 } else {
5354 KeyModifiers::NONE
5355 };
5356 InputEvent::Key(KeyEvent::new(code, mods))
5357 }
5358
5359 #[tokio::test(flavor = "multi_thread")]
5364 async fn ctrl_r_sorts_the_search_browser_and_close_restores_it() {
5365 use ratatui::crossterm::event::KeyCode;
5366 let (mut screen, tx, _rx) = make_editor().await;
5367 let focus = screen.panels.focused();
5368 screen.open_overlay(OverlayOpen::SearchBrowser, &tx);
5369 screen.handle_input(&key(KeyCode::Char('x'), false), &tx);
5370 assert_eq!(screen.overlays.active_query(), Some("x"));
5371
5372 screen.handle_input(&key(KeyCode::Char('r'), true), &tx);
5373 assert_eq!(screen.overlays.active_kind(), Some(OverlayKind::Dialog));
5374 assert_eq!(
5375 screen.overlays.parked_kind(),
5376 Some(OverlayKind::NoteBrowser),
5377 "the browser is parked under the dialog"
5378 );
5379
5380 screen
5381 .handle_app_message(
5382 AppEvent::SortChanged {
5383 target: SortTarget::Browser,
5384 state: SortState {
5385 field: SortField::Title,
5386 order: SortOrder::Ascending,
5387 group_dirs: None,
5388 },
5389 persist: false,
5390 },
5391 &tx,
5392 )
5393 .await;
5394
5395 screen.handle_app_message(AppEvent::CloseOverlay, &tx).await;
5396 assert_eq!(
5397 screen.overlays.active_kind(),
5398 Some(OverlayKind::NoteBrowser)
5399 );
5400 assert_eq!(screen.overlays.parked_kind(), None);
5401 assert_eq!(screen.overlays.active_query(), Some("x or:title"));
5402 assert_eq!(screen.panels.focused(), focus, "panel focus unchanged");
5403
5404 screen.handle_app_message(AppEvent::CloseOverlay, &tx).await;
5406 assert!(!screen.overlays.is_open());
5407 assert_eq!(screen.panels.focused(), focus);
5408 }
5409
5410 #[tokio::test(flavor = "multi_thread")]
5413 async fn ctrl_r_over_the_file_finder_opens_the_sort_dialog() {
5414 use ratatui::crossterm::event::KeyCode;
5415 let (mut screen, tx, _rx) = make_editor().await;
5416 let focus = screen.panels.focused();
5417 screen.open_overlay(OverlayOpen::FileFinder, &tx);
5418 screen.handle_input(&key(KeyCode::Char('r'), true), &tx);
5419 assert_eq!(screen.overlays.active_kind(), Some(OverlayKind::Dialog));
5420 assert_eq!(
5421 screen.overlays.parked_kind(),
5422 Some(OverlayKind::NoteBrowser),
5423 "the finder is parked under the dialog"
5424 );
5425 screen.handle_app_message(AppEvent::CloseOverlay, &tx).await;
5426 assert_eq!(
5427 screen.overlays.active_kind(),
5428 Some(OverlayKind::NoteBrowser)
5429 );
5430 screen.handle_app_message(AppEvent::CloseOverlay, &tx).await;
5431 assert!(!screen.overlays.is_open());
5432 assert_eq!(screen.panels.focused(), focus);
5433 }
5434
5435 #[tokio::test(flavor = "multi_thread")]
5438 async fn sort_dialog_over_a_fresh_file_finder_says_unsorted() {
5439 use ratatui::crossterm::event::KeyCode;
5440 let (mut screen, tx, _rx) = make_editor().await;
5441 assert!(!screen.panels.sidebar().is_unsorted());
5442 assert!(!screen.panels.query().is_unsorted());
5443 screen.open_overlay(OverlayOpen::FileFinder, &tx);
5444 screen.handle_input(&key(KeyCode::Char('r'), true), &tx);
5445 assert_eq!(screen.overlays.active_kind(), Some(OverlayKind::Dialog));
5446 let mut term = ratatui::Terminal::new(ratatui::backend::TestBackend::new(120, 40)).unwrap();
5447 term.draw(|f| screen.render(f)).unwrap();
5448 let text: String = term
5449 .backend()
5450 .buffer()
5451 .content()
5452 .iter()
5453 .map(|c| c.symbol())
5454 .collect();
5455 assert!(text.contains("Unsorted"), "dialog shows Unsorted");
5456 }
5457
5458 #[tokio::test(flavor = "multi_thread")]
5461 async fn sidebar_property_sort_is_never_persisted() {
5462 let (mut screen, tx, mut rx) = make_editor().await;
5463 screen
5464 .handle_app_message(
5465 AppEvent::SortChanged {
5466 target: SortTarget::Sidebar,
5467 state: SortState {
5468 field: SortField::Property("due".into()),
5469 order: SortOrder::Descending,
5470 group_dirs: Some(true),
5471 },
5472 persist: true,
5473 },
5474 &tx,
5475 )
5476 .await;
5477 {
5478 let s = screen.settings.read().unwrap();
5479 assert_eq!(
5480 s.default_sort_field,
5481 crate::settings::SortFieldSetting::Name
5482 );
5483 assert_eq!(
5484 s.default_sort_order,
5485 crate::settings::SortOrderSetting::Ascending
5486 );
5487 assert!(!s.group_directories);
5488 }
5489 assert_eq!(
5490 screen.panels.sidebar().current_sort(),
5491 (SortField::Property("due".into()), SortOrder::Descending),
5492 "the sort still applies live"
5493 );
5494 let mut flashed = false;
5495 while let Ok(evt) = rx.try_recv() {
5496 if let AppEvent::FlashMessage(msg) = evt {
5497 flashed |= msg.contains("can't be saved");
5498 }
5499 }
5500 assert!(flashed, "the user is told why nothing was saved");
5501 }
5502
5503 async fn browser_under_sort_dialog(screen: &mut EditorScreen, tx: &AppTx) {
5505 use ratatui::crossterm::event::KeyCode;
5506 screen.open_overlay(OverlayOpen::SearchBrowser, tx);
5507 screen.handle_input(&key(KeyCode::Char('r'), true), tx);
5508 assert_eq!(
5509 screen.overlays.parked_kind(),
5510 Some(OverlayKind::NoteBrowser)
5511 );
5512 }
5513
5514 #[tokio::test(flavor = "multi_thread")]
5516 async fn entry_op_closes_the_whole_overlay_stack() {
5517 let (mut screen, tx, _rx) = make_editor().await;
5518 let focus = screen.panels.focused();
5519 browser_under_sort_dialog(&mut screen, &tx).await;
5520 screen
5521 .on_entry_op(VaultPath::note_path_from("elsewhere.md"), &tx)
5522 .await;
5523 assert!(!screen.overlays.is_open());
5524 assert_eq!(screen.overlays.parked_kind(), None);
5525 assert_eq!(screen.panels.focused(), focus);
5526 }
5527
5528 #[tokio::test(flavor = "multi_thread")]
5530 async fn note_rename_closes_the_whole_overlay_stack() {
5531 let (mut screen, tx, _rx) = make_editor().await;
5532 browser_under_sort_dialog(&mut screen, &tx).await;
5533 screen
5534 .on_note_renamed(
5535 VaultPath::note_path_from("a.md"),
5536 VaultPath::note_path_from("b.md"),
5537 &tx,
5538 )
5539 .await;
5540 assert!(!screen.overlays.is_open());
5541 assert_eq!(screen.overlays.parked_kind(), None);
5542 }
5543}