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::Backlinks => {
746 self.execute_leader_action(
747 LeaderAction::LinksTab(crate::components::drawer_views::LinksTab::Backlinks),
748 tx,
749 );
750 }
751 FooterTarget::Link => {
752 self.follow_link_at_cursor(tx);
753 }
754 }
755 true
756 }
757
758 fn title_target_at(&self, col: u16, row: u16) -> Option<TitleTarget> {
760 crate::components::clickable::target_at(&self.title_targets, col, row)
761 }
762
763 fn input_ctx(&self, double_click: bool) -> InputCtx {
770 InputCtx {
771 overlay: self.overlays.active_kind(),
772 leader_pending: self.leader.is_pending(),
773 focused: self.panels.focused(),
774 drawer_view: self.panels.active_drawer_view(),
775 space_leads: self.panels.editor().is_some_and(|e| e.space_leads()),
776 claim: self.panels.editor().map(|e| e.claim()).unwrap_or_default(),
777 double_click,
778 overlay_sortable: self.overlays.active_sortable().is_some(),
779 overlay_query: self.overlays.active_takes_query_syntax(),
780 pointer_on_editor: false,
781 }
782 }
783
784 fn input_ctx_for(&self, event: &InputEvent, double_click: bool) -> InputCtx {
788 InputCtx {
789 pointer_on_editor: match event {
790 InputEvent::Mouse(m) => self.panels.pointer_on_editor(m.column, m.row),
791 _ => false,
792 },
793 ..self.input_ctx(double_click)
794 }
795 }
796
797 fn track_click(&mut self, event: &InputEvent, now: std::time::Instant) -> bool {
821 let InputEvent::Mouse(mouse) = event else {
822 self.clicks.end();
823 return false;
824 };
825 if self.overlays.is_open() {
826 self.clicks.end();
827 return false;
828 }
829 let on_buffer = matches!(
834 mouse.kind,
835 ratatui::crossterm::event::MouseEventKind::Down(_)
836 ) && self.panels.is_note_buffer_cell(mouse.column, mouse.row);
837 self.clicks.observe(mouse, on_buffer, now)
838 }
839
840 fn apply_classification(
844 &mut self,
845 classification: Classification,
846 event: &InputEvent,
847 tx: &AppTx,
848 ) -> EventState {
849 if let Some(chord) = classification.flash {
850 self.footer.flash(chord, tx);
851 }
852 if classification.cancel_leader {
853 self.leader.cancel();
854 }
855 self.execute_intent(classification.intent, event, tx)
856 }
857
858 fn execute_intent(
859 &mut self,
860 intent: EditorIntent,
861 event: &InputEvent,
862 tx: &AppTx,
863 ) -> EventState {
864 match intent {
865 EditorIntent::Consume => EventState::Consumed,
866 EditorIntent::EditorPaste => {
867 if !self.try_paste_image(tx)
868 && let InputEvent::Paste(text) = event
869 && !text.is_empty()
870 && let Some(ed) = self.panels.editor_mut()
871 {
872 ed.paste_text(text, tx);
873 }
874 EventState::Consumed
875 }
876 EditorIntent::ImageProbe => {
877 if self.try_paste_image(tx) {
878 EventState::Consumed
879 } else {
880 let ctx = self.input_ctx_for(event, false);
886 let classification = {
887 let s = self.settings.read().unwrap();
888 crate::app_screen::editor_input::classify_tail(
889 event,
890 &s.key_bindings,
891 &ctx,
892 false,
893 )
894 };
895 self.apply_classification(classification, event, tx)
896 }
897 }
898 EditorIntent::FollowLink => {
899 if self.follow_link_at_cursor(tx) {
900 EventState::Consumed
901 } else if matches!(event, InputEvent::Mouse(_)) {
902 self.execute_intent(EditorIntent::Mouse, event, tx)
910 } else {
911 EventState::Consumed
912 }
913 }
914 EditorIntent::LeaderKey(key) => self.handle_leader_key(&key, tx),
915 EditorIntent::LeaderStart => {
916 self.leader.start();
917 self.schedule_whichkey_reveal(tx);
918 EventState::Consumed
919 }
920 EditorIntent::Op(op) => {
921 self.run_op(op, tx);
922 EventState::Consumed
923 }
924 EditorIntent::ToggleOverlay { kind, open } => {
925 if self.overlays.active_kind() == Some(kind) {
926 self.dismiss_overlay();
927 } else {
928 self.open_overlay(open, tx);
929 }
930 EventState::Consumed
931 }
932 EditorIntent::OpenOverlay(open) => {
933 self.open_overlay(open, tx);
934 EventState::Consumed
935 }
936 EditorIntent::Overlay => self.overlays.handle_input(event, tx),
937 EditorIntent::Mouse => {
938 if let InputEvent::Mouse(m) = event
941 && crate::components::clickable::is_left_press(m)
942 && self.click_chrome(m.column, m.row, tx)
943 {
944 return EventState::Consumed;
945 }
946 let state = self.panels.handle_mouse(event, tx);
950 self.ask.sync_sources(&mut self.panels, tx);
952 if self.panels.editor().is_some_and(|e| e.wants_context_menu) {
956 if let Some(ed) = self.panels.editor_mut() {
957 ed.wants_context_menu = false;
958 }
959 tx.send(AppEvent::FileOp(FileOp::ShowMenu(self.path.clone())))
960 .ok();
961 }
962 state
963 }
964 EditorIntent::Panel { fallback } => {
965 let state = self.panels.handle_input(event, tx);
966 self.ask.sync_sources(&mut self.panels, tx);
969 if state == EventState::NotConsumed {
970 match fallback {
971 PanelFallback::None => state,
972 PanelFallback::FocusCycle(CycleDir::Right) => {
973 self.focus_right(tx);
974 EventState::Consumed
975 }
976 PanelFallback::FocusCycle(CycleDir::Left) => {
977 self.focus_left(tx);
978 EventState::Consumed
979 }
980 PanelFallback::FocusEditor => {
981 self.focus_editor();
982 EventState::Consumed
983 }
984 }
985 } else {
986 state
987 }
988 }
989 }
990 }
991
992 fn run_op(&mut self, op: EditorOp, tx: &AppTx) {
993 match op {
994 EditorOp::ToggleDrawer => self.toggle_drawer(tx),
995 EditorOp::FocusLeft => self.focus_left(tx),
996 EditorOp::FocusRight => self.focus_right(tx),
997 EditorOp::OpenJournal => {
998 tx.send(AppEvent::OpenJournal).ok();
999 }
1000 EditorOp::ShowFileOps => {
1001 tx.send(AppEvent::FileOp(FileOp::ShowMenu(self.path.clone())))
1002 .ok();
1003 }
1004 EditorOp::ToggleQueryPanel => self.toggle_backlinks(tx),
1005 EditorOp::OpenFileBrowserReveal => {
1006 self.open_drawer_view(DrawerView::Files, tx);
1007 self.reveal_note_dir_in_sidebar(tx);
1008 }
1009 EditorOp::SaveCurrentQuery => {
1010 if let Some((query, provenance, source)) = self.save_query_source() {
1011 self.present_overlay(Box::new(ActiveDialog::save_search(
1015 query,
1016 provenance,
1017 source,
1018 self.vault.clone(),
1019 tx,
1020 )));
1021 }
1022 }
1023 EditorOp::FindInBuffer => {
1024 if let Some(ed) = self.panels.editor_mut() {
1025 ed.open_or_advance_search();
1026 }
1027 }
1028 EditorOp::ReplaceInBuffer => {
1029 if let Some(ed) = self.panels.editor_mut() {
1030 ed.open_replace();
1031 }
1032 }
1033 EditorOp::ApplyText(text_action) => {
1034 if let Some(ed) = self.panels.editor_mut() {
1035 ed.apply_text_action(text_action);
1036 }
1037 }
1038 EditorOp::OpenAsk => self.open_ask_workspace(tx),
1039 }
1040 }
1041
1042 fn open_overlay(&mut self, open: OverlayOpen, tx: &AppTx) {
1052 let stacks = matches!(
1053 open,
1054 OverlayOpen::SortBrowser | OverlayOpen::QueryHelpOverBrowser
1055 );
1056 if self.overlays.is_open() != stacks {
1057 return;
1058 }
1059 let Some(overlay) = self.build_overlay(open, tx) else {
1060 return;
1061 };
1062 if stacks {
1063 self.leader.cancel();
1064 self.overlays.open_over(overlay);
1065 } else {
1066 self.present_overlay(overlay);
1067 }
1068 }
1069
1070 fn sortable(&self, target: SortTarget) -> Option<&dyn SortableList> {
1073 match target {
1074 SortTarget::Sidebar => Some(self.panels.sidebar()),
1075 SortTarget::Query => Some(self.panels.query()),
1076 SortTarget::Browser => self.overlays.active_sortable(),
1077 }
1078 }
1079
1080 fn sortable_mut(&mut self, target: SortTarget) -> Option<&mut dyn SortableList> {
1083 match target {
1084 SortTarget::Sidebar => Some(self.panels.sidebar_mut()),
1085 SortTarget::Query => Some(self.panels.query_mut()),
1086 SortTarget::Browser => self.overlays.parked_sortable_mut(),
1087 }
1088 }
1089
1090 fn sort_dialog(&self, target: SortTarget, tx: &AppTx) -> Option<Box<dyn Overlay>> {
1094 let list = self.sortable(target)?;
1095 let allows_property = list.allows_property();
1096 Some(Box::new(ActiveDialog::sort(
1097 target,
1098 list.sort_state(),
1099 list.is_unsorted(),
1100 allows_property,
1101 allows_property.then(|| self.vault.clone()),
1102 tx,
1103 )))
1104 }
1105
1106 fn open_note(&self) -> Option<&VaultPath> {
1114 if self.panels.is_showing_ask() || !self.path.is_note() {
1115 None
1116 } else {
1117 Some(&self.path)
1118 }
1119 }
1120
1121 fn open_note_or_flash(&self, tx: &AppTx) -> Option<VaultPath> {
1123 let note = self.open_note().cloned();
1124 if note.is_none() {
1125 tx.send(AppEvent::FlashMessage("no note open".into())).ok();
1126 }
1127 note
1128 }
1129
1130 fn toggle_pin_open_note(&self, tx: &AppTx) {
1135 let Some(path) = self.open_note_or_flash(tx) else {
1136 return;
1137 };
1138 let vault = self.vault.clone();
1139 let tx = tx.clone();
1140 tokio::spawn(async move {
1141 let msg = match vault.toggle_pinned_note(&path).await {
1142 Ok(kimun_core::PinToggle::Pinned { position }) => format!(
1143 "pinned as {} of {}",
1144 position + 1,
1145 kimun_core::PINNED_NOTES_CAP
1146 ),
1147 Ok(kimun_core::PinToggle::Unpinned) => "unpinned".to_string(),
1148 Ok(kimun_core::PinToggle::Full) => format!(
1149 "already {} pinned — unpin one first",
1150 kimun_core::PINNED_NOTES_CAP
1151 ),
1152 Err(VaultError::FSError(FSError::VaultPathNotFound { .. })) => {
1153 format!("note not found: {path}")
1154 }
1155 Err(e) => {
1156 tracing::warn!("failed to toggle pin on {path}: {e}");
1157 format!("could not update pinned notes: {e}")
1158 }
1159 };
1160 tx.send(AppEvent::FlashMessage(msg)).ok();
1161 });
1162 }
1163
1164 fn jump_to_pinned(&self, n: u8, tx: &AppTx) {
1167 let vault = self.vault.clone();
1168 let tx = tx.clone();
1169 tokio::spawn(async move {
1170 let pins = match vault.list_pinned_notes().await {
1171 Ok(pins) => pins,
1172 Err(e) => {
1173 tx.send(AppEvent::FlashMessage(format!(
1174 "could not read pinned notes: {e}"
1175 )))
1176 .ok();
1177 return;
1178 }
1179 };
1180 let Some(path) = pins.get(usize::from(n).saturating_sub(1)).cloned() else {
1181 tx.send(AppEvent::FlashMessage(format!("no pinned note {n}")))
1182 .ok();
1183 return;
1184 };
1185 if !vault.exists(&path).await {
1186 tx.send(AppEvent::FlashMessage(format!(
1187 "pinned note not found: {path}"
1188 )))
1189 .ok();
1190 return;
1191 }
1192 tx.send(AppEvent::OpenPath {
1193 path,
1194 emphasis: None,
1195 })
1196 .ok();
1197 });
1198 }
1199
1200 fn apply_sort(&mut self, target: SortTarget, state: &SortState, tx: &AppTx) {
1204 if let Some(list) = self.sortable_mut(target) {
1205 list.apply_sort(state, tx);
1206 }
1207 }
1208
1209 fn persist_sidebar_sort(&mut self, state: &SortState) {
1214 let is_journal = self.panels.sidebar().is_current_journal();
1215 self.panels
1216 .sidebar_mut()
1217 .remember_default(state.field.clone(), state.order);
1218 {
1219 let mut s = self.settings.write().unwrap();
1220 let field = crate::settings::SortFieldSetting::from(state.field.clone());
1221 let order = crate::settings::SortOrderSetting::from(state.order);
1222 if is_journal {
1223 s.journal_sort_field = field;
1224 s.journal_sort_order = order;
1225 } else {
1226 s.default_sort_field = field;
1227 s.default_sort_order = order;
1228 }
1229 if let Some(group) = state.group_dirs {
1230 s.group_directories = group;
1231 }
1232 }
1233 let snapshot = self.settings.read().unwrap().clone();
1234 tokio::spawn(async move {
1235 snapshot.save_to_disk().ok();
1236 });
1237 }
1238
1239 fn build_overlay(&self, open: OverlayOpen, tx: &AppTx) -> Option<Box<dyn Overlay>> {
1244 let s = self.settings.read().unwrap();
1245 let overlay: Box<dyn Overlay> = match open {
1246 OverlayOpen::SearchBrowser => {
1249 let provider = resolving_search_source(
1250 self.vault.clone(),
1251 s.current_last_paths(),
1252 Some(self.path.clone()),
1253 );
1254 Box::new(NoteBrowserModal::new(
1255 "Note Browser",
1256 BrowserScope::Query,
1257 provider,
1258 self.vault.clone(),
1259 s.key_bindings.clone(),
1260 s.icons(),
1261 tx.clone(),
1262 ))
1263 }
1264 OverlayOpen::FileFinder => {
1267 let current_dir = self.path.get_parent_path().0;
1268 let provider = FileFinderProvider::new(self.vault.clone(), current_dir);
1269 Box::new(
1270 NoteBrowserModal::new(
1271 "Find Note",
1272 BrowserScope::Files,
1273 provider,
1274 self.vault.clone(),
1275 s.key_bindings.clone(),
1276 s.icons(),
1277 tx.clone(),
1278 )
1279 .with_row_sort(),
1280 )
1281 }
1282 OverlayOpen::SavedSearches => Box::new(SavedSearchesModal::new(
1284 self.vault.clone(),
1285 s.key_bindings.clone(),
1286 s.icons(),
1287 tx.clone(),
1288 )),
1289 OverlayOpen::CommandPalette => {
1291 let gateway = s
1292 .key_bindings
1293 .first_combo_for(&ActionShortcuts::Leader)
1294 .unwrap_or_else(|| "leader".to_string());
1295 Box::new(
1296 crate::components::command_palette::CommandPaletteModal::new(
1297 &s.leader_tree(),
1298 &gateway,
1299 s.icons(),
1300 tx.clone(),
1301 ),
1302 )
1303 }
1304 OverlayOpen::WorkspaceSwitcher => Box::new(ActiveDialog::workspace_switcher(&s)),
1306 OverlayOpen::ThemePicker => Box::new(ActiveDialog::theme_picker(&s)),
1309 OverlayOpen::Help => Box::new(ActiveDialog::help(&s.key_bindings, &s.leader_tree())),
1310 OverlayOpen::QueryHelp | OverlayOpen::QueryHelpOverBrowser => {
1311 Box::new(ActiveDialog::query_syntax())
1312 }
1313 OverlayOpen::Cheatsheet => Box::new(ActiveDialog::cheatsheet(&s)),
1314 OverlayOpen::SortQuery => return self.sort_dialog(SortTarget::Query, tx),
1315 OverlayOpen::SortSidebar => return self.sort_dialog(SortTarget::Sidebar, tx),
1316 OverlayOpen::SortBrowser => return self.sort_dialog(SortTarget::Browser, tx),
1317 OverlayOpen::QuickNote => Box::new(ActiveDialog::quick_note(self.vault.clone())),
1318 OverlayOpen::PinnedNotes => {
1320 Box::new(ActiveDialog::pinned_notes(self.vault.clone(), tx))
1321 }
1322 };
1323 Some(overlay)
1324 }
1325
1326 fn handle_update(&mut self, flow: UpdateFlow, tx: &AppTx) {
1330 match flow {
1331 UpdateFlow::Available(status) => {
1332 self.update = Some(status);
1333 }
1334 UpdateFlow::ShowDialog => {
1335 if let Some(status) = self.update.clone() {
1336 self.present_overlay(Box::new(ActiveDialog::update(&status)));
1337 }
1338 }
1339 UpdateFlow::Dismiss(_) => {
1340 self.update = None;
1342 }
1343 UpdateFlow::Applied => {
1344 self.update = None;
1347 }
1348 UpdateFlow::Apply => {
1349 let tx2 = tx.clone();
1350 tx.send(AppEvent::FlashMessage("Downloading update…".into()))
1351 .ok();
1352 tokio::spawn(async move {
1353 let result = async {
1354 let latest = crate::update::latest_release().await?;
1355 crate::update::install(latest).await
1356 }
1357 .await;
1358 let msg = match result {
1359 Ok(()) => {
1360 tx2.send(AppEvent::Update(UpdateFlow::Applied)).ok();
1361 "Update installed — restart kimün to apply".to_string()
1362 }
1363 Err(e) => format!("Update failed: {e}"),
1364 };
1365 tx2.send(AppEvent::FlashMessage(msg)).ok();
1366 });
1367 }
1368 }
1369 }
1370
1371 async fn handle_file_op(&mut self, op: FileOp, tx: &AppTx) {
1374 match op {
1375 FileOp::ShowMenu(path) => {
1376 self.present_overlay(Box::new(ActiveDialog::file_ops_menu(path)));
1377 }
1378 FileOp::ShowProperties(path) => {
1379 if path.is_like(&self.path) {
1382 self.try_save().await;
1383 if self.panels.editor().is_some_and(|e| e.is_dirty()) {
1384 self.footer
1385 .flash("save failed — properties not opened".to_string(), tx);
1386 return;
1387 }
1388 }
1389 self.present_overlay(Box::new(ActiveDialog::properties(
1390 path,
1391 self.vault.clone(),
1392 tx,
1393 )));
1394 }
1395 FileOp::ShowDelete(path) => {
1396 self.present_overlay(Box::new(ActiveDialog::delete(path, self.vault.clone())));
1397 }
1398 FileOp::ShowRename(path) => {
1399 self.present_overlay(Box::new(ActiveDialog::rename(path, self.vault.clone())));
1400 }
1401 FileOp::ShowMove(path) => {
1402 self.present_overlay(Box::new(ActiveDialog::move_to(
1403 path,
1404 self.vault.clone(),
1405 tx,
1406 )));
1407 }
1408 FileOp::ShowCreateWithContent { path, content } => {
1409 let path = self.vault.free_note_path(&path).await;
1417 self.present_overlay(Box::new(ActiveDialog::create_note(
1418 path,
1419 self.vault.clone(),
1420 Some(content),
1421 )));
1422 }
1423 FileOp::Created(path) => {
1424 self.refresh_sidebar_if_showing(&path.get_parent_path().0, tx);
1428 }
1429 FileOp::Deleted(path) => {
1430 self.on_entry_op(path, tx).await;
1431 }
1432 FileOp::Renamed { from, to } => {
1433 if from.is_note() {
1436 self.on_note_renamed(from, to, tx).await;
1437 } else {
1438 self.on_entry_op(from, tx).await;
1439 }
1440 }
1441 FileOp::Moved { from, .. } => {
1442 self.on_entry_op(from, tx).await;
1443 }
1444 }
1445 }
1446
1447 fn handle_saved_search(&mut self, flow: SavedSearchFlow, tx: &AppTx) {
1449 match flow {
1450 SavedSearchFlow::Selected { query, name } => {
1451 self.overlays.close();
1455 self.apply_saved_search(query, name, tx);
1456 }
1457 SavedSearchFlow::Confirmed {
1458 name,
1459 query,
1460 source,
1461 } => {
1462 let vault = self.vault.clone();
1466 let tx = tx.clone();
1467 tokio::spawn(async move {
1468 match vault.save_search(&name, &query).await {
1469 Ok(()) => {
1470 tx.send(AppEvent::SavedSearch(SavedSearchFlow::Persisted {
1471 name,
1472 query,
1473 source,
1474 }))
1475 .ok();
1476 }
1477 Err(e) => {
1478 tracing::warn!("failed to save search '{}': {}", name, e);
1479 tx.send(AppEvent::SavedSearch(SavedSearchFlow::SaveFailed { name }))
1480 .ok();
1481 }
1482 }
1483 });
1484 }
1485 SavedSearchFlow::Persisted {
1492 name,
1493 query,
1494 source: SaveSource::QueryPanel,
1495 } if name.trim() != query.trim() => {
1496 self.panels.query_mut().repin_saved_search(name, &query);
1497 }
1498 SavedSearchFlow::Persisted { .. } => {}
1499 SavedSearchFlow::SaveFailed { name } => {
1500 self.footer
1501 .flash(format!("Failed to save search '{name}'"), tx);
1502 }
1503 }
1504 }
1505
1506 async fn handle_owned_message(&mut self, msg: AppEvent, tx: &AppTx) {
1509 match msg {
1510 AppEvent::RagStatus(status) => {
1511 let ask_was = self.rag_status.llm_available();
1512 let sem_was = self.rag_status.search_available();
1513 self.rag_status = status;
1514 let ask_now = status.llm_available();
1520 let sem_now = status.search_available();
1521 if ask_was != ask_now || sem_was != sem_now {
1522 let (kb, icons) = {
1523 let s = self.settings.read().unwrap();
1524 (s.key_bindings.clone(), s.icons())
1525 };
1526 self.panels.rebuild_rail(
1527 kb,
1528 icons,
1529 crate::components::activity_rail::RailCaps {
1530 semantic: sem_now,
1531 ask: ask_now,
1532 },
1533 );
1534 }
1535 if ask_was != ask_now {
1536 self.ask
1537 .refresh_capability(&mut self.panels, self.rag_status)
1538 .await;
1539 }
1540 }
1541 AppEvent::ServerUpdate(latest) => self.server_update = latest,
1542 AppEvent::Ask(data) => self.ask.handle_data(&mut self.panels, data, tx),
1543 AppEvent::FlashMessage(msg) => {
1546 self.footer.flash(msg, tx);
1547 }
1548 AppEvent::OpenAttachmentExternally => self.open_attachment_externally(tx),
1549 AppEvent::OpenQueryHelp => {
1550 let open = if self.overlays.active_takes_query_syntax() {
1553 Some(OverlayOpen::QueryHelpOverBrowser)
1554 } else if !self.overlays.is_open() {
1555 Some(OverlayOpen::QueryHelp)
1556 } else {
1557 None
1558 };
1559 if let Some(open) = open {
1560 self.open_overlay(open, tx);
1561 }
1562 }
1563 AppEvent::OpenSortDialog(target) => {
1564 let open = match target {
1568 SortTarget::Sidebar | SortTarget::Query if self.overlays.is_open() => None,
1569 SortTarget::Sidebar => Some(OverlayOpen::SortSidebar),
1570 SortTarget::Query => Some(OverlayOpen::SortQuery),
1571 SortTarget::Browser => self
1572 .overlays
1573 .active_sortable()
1574 .is_some()
1575 .then_some(OverlayOpen::SortBrowser),
1576 };
1577 if let Some(open) = open {
1578 self.open_overlay(open, tx);
1579 }
1580 }
1581 AppEvent::ExecuteLeaderAction(action) => {
1582 if self.overlays.is_open() {
1583 tracing::warn!("ExecuteLeaderAction({action:?}) dropped: overlay open");
1586 } else {
1587 self.execute_leader_action(action, tx);
1588 }
1589 }
1590 AppEvent::ApplyTheme { theme, persist } => {
1591 {
1594 let mut s = self.settings.write().unwrap();
1595 s.set_theme(theme.name.clone());
1596 }
1597 self.theme = (*theme).adapt_to_terminal();
1598 if persist {
1599 let snapshot = self.settings.read().unwrap().clone();
1600 tokio::spawn(async move {
1601 snapshot.save_to_disk().ok();
1602 });
1603 }
1604 tx.send(AppEvent::Redraw).ok();
1605 }
1606 AppEvent::RunTagQuery(label) if !self.overlays.is_open() => {
1609 let term = kimun_core::quote_query_term(&label);
1611 self.open_find_with_query(format!("#{term}"), None, tx);
1612 }
1613 AppEvent::JumpToHeading(target) if !self.overlays.is_open() => {
1614 self.panels.sync_outline(&self.path, tx);
1618 let row = self.panels.outline_mut().resolve(&target);
1619 match (self.panels.editor_mut(), row) {
1620 (Some(ed), Some(row)) => ed.jump_to_row(row),
1621 (None, _) => self.footer.flash("no note open".to_string(), tx),
1624 (Some(_), None) => self.footer.flash(
1625 format!("heading \"{}\" is no longer in the note", target.text),
1626 tx,
1627 ),
1628 }
1629 self.focus_editor();
1630 }
1631 AppEvent::OpenDrawerView(view) => {
1632 if view == DrawerView::Semantic && !self.rag_status.search_available() {
1636 let msg = if crate::rag::rag_configured(&self.settings) {
1637 "Semantic search needs a reachable server with an embedder"
1638 } else {
1639 "Set kimun_server_url in config to use semantic search"
1640 };
1641 tx.send(AppEvent::FlashMessage(msg.into())).ok();
1642 }
1643 else if view == DrawerView::Ask && !self.rag_status.llm_available() {
1646 tx.send(AppEvent::FlashMessage(
1647 "Ask needs an LLM-configured server; this one is semantic-search only"
1648 .into(),
1649 ))
1650 .ok();
1651 }
1652 else if self.panels.is_visible(PanelKind::Drawer)
1655 && self.panels.active_drawer_view() == view
1656 {
1657 self.panels.hide(PanelKind::Drawer);
1658 } else {
1659 self.open_drawer_view(view, tx);
1660 }
1661 }
1662 AppEvent::CloseOverlay => {
1663 self.dismiss_overlay();
1672 }
1673 AppEvent::SortChanged {
1674 target,
1675 state,
1676 persist,
1677 } => {
1678 self.apply_sort(target, &state, tx);
1679 if persist && target == SortTarget::Sidebar {
1683 if matches!(state.field, SortField::Property(_)) {
1684 tx.send(AppEvent::FlashMessage(
1685 "property sort can't be saved as default".into(),
1686 ))
1687 .ok();
1688 } else {
1689 self.persist_sidebar_sort(&state);
1690 }
1691 }
1692 }
1693 AppEvent::Autosave => {
1694 self.spawn_autosave(tx);
1695 }
1696 AppEvent::NoteReloadFromDisk(path) => {
1697 if path.is_like(&self.path) && self.panels.editor().is_some_and(|e| e.is_dirty()) {
1701 self.footer.flash(
1702 "properties changed on disk — reopen the note to see them".to_string(),
1703 tx,
1704 );
1705 self.doc_meta.refresh_properties(&self.path, tx);
1707 } else if path.is_like(&self.path) {
1708 self.autosave_task.abort();
1709 match self.vault.get_note_text(&self.path).await {
1710 Ok(text) => {
1711 if let Some(ed) = self.panels.editor_mut() {
1712 ed.set_text(text.clone());
1713 ed.mark_saved(text);
1714 }
1715 }
1716 Err(_) => self.footer.flash(
1719 "reload failed — reopen the note before editing".to_string(),
1720 tx,
1721 ),
1722 }
1723 self.doc_meta.refresh_properties(&self.path, tx);
1724 }
1725 }
1726 AppEvent::AutosaveCompleted {
1727 path,
1728 saved_revision,
1729 title,
1730 } => {
1731 if path == self.path
1732 && let Some(rev) = saved_revision
1733 && let Some(ed) = self.panels.editor_mut()
1734 {
1735 ed.mark_saved_at_revision(rev);
1736 }
1737 if let Some(raw_title) = title {
1738 self.note_saved(&path, raw_title);
1739 }
1740 self.doc_meta.refresh_git(tx);
1743 if path == self.path {
1744 self.doc_meta.refresh_properties(&path, tx);
1745 if saved_revision.is_some() && self.drawer_open_on(DrawerView::Outline) {
1750 self.panels.sync_outline(&path, tx);
1751 }
1752 }
1753 }
1762 AppEvent::FocusSidebar => {
1763 self.focus_sidebar(tx);
1764 }
1765 AppEvent::FollowLink(target) => {
1766 self.follow_link(target, tx).await;
1767 }
1768 AppEvent::FollowLabel(name) => {
1769 let initial = format!("#{name}");
1770 let s = self.settings.read().unwrap();
1771 let provider = resolving_search_source(
1772 self.vault.clone(),
1773 s.current_last_paths(),
1774 Some(self.path.clone()),
1775 );
1776 let modal = NoteBrowserModal::with_initial_query(
1777 "Note Browser",
1778 BrowserScope::Query,
1779 provider,
1780 self.vault.clone(),
1781 s.key_bindings.clone(),
1782 s.icons(),
1783 tx.clone(),
1784 initial,
1785 );
1786 drop(s);
1787 self.present_overlay(Box::new(modal));
1788 }
1789 AppEvent::InsertAtCursor(text) if self.panels.focused() == PanelKind::Editor => {
1790 if let Some(ed) = self.panels.editor_mut() {
1791 ed.insert_at_cursor(&text, tx);
1792 }
1793 }
1794 _ => {}
1795 }
1796 }
1797
1798 pub fn focus_editor(&mut self) {
1799 self.panels.focus(PanelKind::Editor);
1800 }
1801
1802 fn drawer_open_on(&self, view: DrawerView) -> bool {
1804 self.panels.is_visible(PanelKind::Drawer) && self.panels.active_drawer_view() == view
1805 }
1806
1807 fn reflect_open_note_in_drawers(&mut self, tx: &AppTx) {
1812 let path = self.path.clone();
1813 if self.drawer_open_on(DrawerView::Find) {
1814 self.panels.query_mut().set_note(path.clone(), tx.clone());
1815 }
1816 if self.panels.is_visible(PanelKind::Drawer) {
1817 match self.panels.active_drawer_view() {
1818 DrawerView::Links => self.panels.links_mut().set_note(path.clone(), tx),
1819 DrawerView::Outline => self.panels.sync_outline(&path, tx),
1820 _ => {}
1821 }
1822 }
1823 }
1824
1825 fn reveal_note_dir_in_sidebar(&mut self, tx: &AppTx) {
1829 let note_parent = self.path.get_parent_path().0;
1830 if self.panels.sidebar().is_empty()
1831 || !note_parent.is_like(self.panels.sidebar().current_dir())
1832 {
1833 self.navigate_sidebar(note_parent, tx);
1834 }
1835 }
1836
1837 pub fn focus_sidebar(&mut self, tx: &AppTx) {
1841 if !self.panels.is_visible(PanelKind::Drawer) {
1842 self.open_drawer_view(DrawerView::Files, tx);
1846 } else {
1847 self.panels.focus(PanelKind::Drawer);
1848 }
1849 }
1850
1851 fn open_drawer_view(&mut self, view: DrawerView, tx: &AppTx) {
1855 self.ask.transition(&mut self.panels, view, tx);
1859 let newly_shown = !self.drawer_open_on(view);
1860 self.panels.open_drawer_view(view);
1861 self.drawer_view_revealed(view, newly_shown, tx);
1862 if view == DrawerView::Ask {
1866 self.panels.focus(PanelKind::Editor);
1867 } else {
1868 self.panels.focus(PanelKind::Drawer);
1869 }
1870 }
1871
1872 fn ensure_ask_workspace(&mut self, tx: &AppTx) {
1876 if !self.panels.is_showing_ask() {
1877 self.open_drawer_view(DrawerView::Ask, tx);
1878 }
1879 }
1880
1881 fn with_ask_panel(&mut self, tx: &AppTx, f: impl FnOnce(&mut ThreadPanel, &AppTx)) {
1885 self.ensure_ask_workspace(tx);
1886 f(self.panels.ask_mut(), tx);
1887 }
1888
1889 fn drawer_view_revealed(&mut self, view: DrawerView, newly_shown: bool, tx: &AppTx) {
1897 match view {
1898 DrawerView::Find if newly_shown => {
1899 self.panels
1900 .query_mut()
1901 .set_note(self.path.clone(), tx.clone());
1902 }
1903 DrawerView::Files if newly_shown => {
1904 self.reveal_note_dir_in_sidebar(tx);
1905 }
1906 DrawerView::Config => {
1907 let info = {
1908 let s = self.settings.read().unwrap();
1909 let key_of = |a: &ActionShortcuts| {
1910 s.key_bindings
1911 .first_combo_for(a)
1912 .unwrap_or_else(|| "unbound".to_string())
1913 };
1914 crate::components::drawer::ConfigInfo {
1915 theme_name: s.get_theme().name,
1916 leader_key: key_of(&ActionShortcuts::Leader),
1917 preferences_key: key_of(&ActionShortcuts::OpenPreferences),
1918 leader_timeout_ms: s.leader_timeout_ms,
1919 config_path: s
1920 .config_file
1921 .as_ref()
1922 .map(|p| p.display().to_string())
1923 .unwrap_or_else(|| "default location".to_string()),
1924 }
1925 };
1926 self.panels.drawer_set_config_info(info);
1927 }
1928 DrawerView::Semantic => self.panels.semantic_mut().ensure_source(tx),
1929 DrawerView::Tags => self.panels.tags_mut().refresh(tx),
1930 DrawerView::Links => self.panels.links_mut().set_note(self.path.clone(), tx),
1931 DrawerView::Outline => self.panels.sync_outline(&self.path, tx),
1932 _ => {}
1933 }
1934 }
1935
1936 fn apply_text_from_leader(&mut self, action: TextAction, tx: &AppTx) {
1946 if self.input_ctx(false).accepts_text_action() {
1947 self.run_op(EditorOp::ApplyText(action), tx);
1948 } else {
1949 self.footer
1950 .flash("formatting needs the note focused".to_string(), tx);
1951 }
1952 }
1953
1954 fn focus_left(&mut self, _tx: &AppTx) {
1956 if let Some(kind) = self.panels.prev_kind() {
1957 self.panels.focus(kind);
1958 }
1959 }
1960
1961 fn focus_right(&mut self, _tx: &AppTx) {
1963 if let Some(kind) = self.panels.next_kind() {
1964 self.panels.focus(kind);
1965 }
1966 }
1967
1968 fn toggle_drawer(&mut self, tx: &AppTx) {
1975 if self.panels.is_visible(PanelKind::Drawer) {
1976 self.panels.hide(PanelKind::Drawer);
1977 } else {
1978 self.panels.show(PanelKind::Drawer);
1979 self.drawer_view_revealed(self.panels.active_drawer_view(), true, tx);
1980 }
1981 }
1982
1983 fn save_query_source(&self) -> Option<(String, Option<String>, SaveSource)> {
1989 let (query, provenance, source) = match self.overlays.active_kind() {
1990 Some(OverlayKind::NoteBrowser) => (
1991 self.overlays.active_query().unwrap_or_default().to_string(),
1992 self.overlays
1993 .active_saved_search_provenance()
1994 .map(str::to_string),
1995 SaveSource::NoteBrowser,
1996 ),
1997 None => (
1998 self.panels.query().active_query().to_string(),
1999 self.panels.query().saved_search_name().map(str::to_string),
2000 SaveSource::QueryPanel,
2001 ),
2002 Some(_) => return None,
2003 };
2004 if query.trim().is_empty() {
2005 None
2006 } else {
2007 Some((query, provenance, source))
2008 }
2009 }
2010
2011 fn schedule_whichkey_reveal(&self, tx: &AppTx) {
2016 let timeout = {
2017 let s = self.settings.read().unwrap();
2018 std::time::Duration::from_millis(s.leader_timeout_ms)
2019 };
2020 let tx2 = tx.clone();
2021 tokio::spawn(async move {
2022 tokio::time::sleep(timeout + std::time::Duration::from_millis(10)).await;
2023 let _ = tx2.send(AppEvent::Redraw);
2024 });
2025 }
2026
2027 fn open_ask_workspace(&mut self, tx: &AppTx) {
2036 if self.overlays.is_open() {
2037 return;
2038 }
2039 if !self.panels.is_showing_ask() {
2040 if !self.rag_status.llm_available() {
2041 tx.send(AppEvent::FlashMessage(
2042 "Ask needs an LLM-configured server; this one is semantic-search only".into(),
2043 ))
2044 .ok();
2045 return;
2046 }
2047 self.open_drawer_view(DrawerView::Ask, tx);
2048 }
2049 self.panels.ask_mut().focus_composer();
2050 self.panels.focus(PanelKind::Editor);
2051 }
2052
2053 fn follow_link_at_cursor(&mut self, tx: &AppTx) -> bool {
2061 use crate::components::text_editor::FollowTarget;
2062 if self.panels.is_showing_attachment() {
2065 self.open_attachment_externally(tx);
2066 return true;
2067 }
2068 let Some(editor) = self.panels.editor_mut() else {
2069 return false;
2070 };
2071 match editor.follow_target_at_cursor() {
2072 Some(FollowTarget::Link(target)) => {
2073 tx.send(AppEvent::FollowLink(target)).ok();
2074 true
2075 }
2076 Some(FollowTarget::Label(name)) => {
2077 tx.send(AppEvent::FollowLabel(name)).ok();
2078 true
2079 }
2080 None => false,
2081 }
2082 }
2083
2084 fn open_attachment_externally(&mut self, tx: &AppTx) {
2087 let Some(path) = self.panels.attachment_path() else {
2088 return;
2089 };
2090 let os_path = self.vault.path_to_pathbuf(path);
2091 match open::that_detached(&os_path) {
2092 Ok(()) => self
2093 .footer
2094 .flash(format!("Opening {}", os_path.display()), tx),
2095 Err(e) => self.footer.flash(format!("Cannot open: {e}"), tx),
2096 }
2097 }
2098
2099 fn handle_leader_key(
2104 &mut self,
2105 key: &ratatui::crossterm::event::KeyEvent,
2106 tx: &AppTx,
2107 ) -> EventState {
2108 use ratatui::crossterm::event::{KeyCode, KeyModifiers};
2109 match key.code {
2110 KeyCode::Esc => {
2111 self.leader.cancel();
2112 self.focus_editor();
2113 }
2114 KeyCode::Backspace => {
2115 let outcome = self.leader.step_up();
2116 if outcome == LeaderOutcome::SteppedUp {
2118 self.schedule_whichkey_reveal(tx);
2119 }
2120 }
2121 KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
2122 match self.leader.feed(c) {
2123 LeaderOutcome::Fired(action) => self.execute_leader_action(action, tx),
2124 LeaderOutcome::Invalid => {
2125 self.footer.flash(format!("leader: no entry for '{c}'"), tx);
2127 self.schedule_whichkey_reveal(tx);
2128 }
2129 LeaderOutcome::Descended => self.schedule_whichkey_reveal(tx),
2130 _ => {}
2131 }
2132 }
2133 _ => {}
2137 }
2138 tx.send(AppEvent::Redraw).ok();
2139 EventState::Consumed
2140 }
2141
2142 fn execute_leader_action(&mut self, action: LeaderAction, tx: &AppTx) {
2145 match action {
2146 LeaderAction::OpenDrawer(view) => self.open_drawer_view(view, tx),
2147
2148 LeaderAction::AppCheckUpdates => {
2149 if let Some(status) = self.update.clone() {
2150 self.present_overlay(Box::new(ActiveDialog::update(&status)));
2151 } else {
2152 let tx2 = tx.clone();
2154 tx.send(AppEvent::FlashMessage("Checking for updates…".into()))
2155 .ok();
2156 tokio::spawn(async move {
2157 let Ok(config_dir) = crate::settings::config_dir() else {
2158 return;
2159 };
2160 match crate::update::check_now(config_dir, true).await {
2161 Ok(Some(status)) if status.update_available => {
2162 tx2.send(AppEvent::Update(UpdateFlow::Available(status)))
2166 .ok();
2167 tx2.send(AppEvent::Update(UpdateFlow::ShowDialog)).ok();
2168 }
2169 Ok(_) => {
2170 tx2.send(AppEvent::FlashMessage("kimün is up to date".into()))
2171 .ok();
2172 }
2173 Err(e) => {
2174 tx2.send(AppEvent::FlashMessage(format!(
2175 "Update check failed: {e}"
2176 )))
2177 .ok();
2178 }
2179 }
2180 });
2181 }
2182 }
2183
2184 LeaderAction::FindFiles => self.open_overlay(OverlayOpen::FileFinder, tx),
2187 LeaderAction::FindGrep => self.open_overlay(OverlayOpen::SearchBrowser, tx),
2188 LeaderAction::FindTags => self.open_drawer_view(DrawerView::Tags, tx),
2189 LeaderAction::FindBacklinks => {
2190 self.open_find_with_query("<{note}".to_string(), None, tx)
2191 }
2192 LeaderAction::FindSaved => self.open_overlay(OverlayOpen::SavedSearches, tx),
2193 LeaderAction::FindRecent => self.open_overlay(OverlayOpen::SearchBrowser, tx),
2194 LeaderAction::FindHeadings => self.open_drawer_view(DrawerView::Outline, tx),
2195
2196 LeaderAction::NoteNew => {
2198 self.open_drawer_view(DrawerView::Files, tx);
2202 self.footer
2203 .flash("type a name — Enter creates".to_string(), tx);
2204 }
2205 LeaderAction::NoteDaily => {
2206 tx.send(AppEvent::OpenJournal).ok();
2207 }
2208 LeaderAction::NoteFromTemplate => {
2209 self.footer.flash("templates — coming soon".to_string(), tx);
2210 }
2211 LeaderAction::NoteRename => {
2212 if let Some(path) = self.open_note_or_flash(tx) {
2213 tx.send(AppEvent::FileOp(FileOp::ShowRename(path))).ok();
2214 }
2215 }
2216 LeaderAction::NoteMove => {
2217 if let Some(path) = self.open_note_or_flash(tx) {
2218 tx.send(AppEvent::FileOp(FileOp::ShowMove(path))).ok();
2219 }
2220 }
2221 LeaderAction::NoteProperties => {
2222 if let Some(path) = self.open_note_or_flash(tx) {
2223 tx.send(AppEvent::FileOp(FileOp::ShowProperties(path))).ok();
2224 }
2225 }
2226 LeaderAction::NoteDelete => {
2227 if let Some(path) = self.open_note_or_flash(tx) {
2228 tx.send(AppEvent::FileOp(FileOp::ShowDelete(path))).ok();
2229 }
2230 }
2231
2232 LeaderAction::LinksTab(tab) => {
2234 self.open_drawer_view(DrawerView::Links, tx);
2235 self.panels.links_mut().show_tab(tab, tx);
2236 }
2237 LeaderAction::LinksGraph => {
2238 self.footer
2239 .flash("local graph — coming soon".to_string(), tx);
2240 }
2241
2242 LeaderAction::GitStatus => {
2245 self.doc_meta.refresh_git(tx);
2246 let msg = self
2247 .doc_meta
2248 .git()
2249 .cloned()
2250 .unwrap_or_else(|| "not a git repository".to_string());
2251 self.footer.flash(msg, tx);
2252 }
2253 LeaderAction::GitSync | LeaderAction::GitLog | LeaderAction::GitDiff => {
2254 self.footer
2255 .flash("git is display-only for now".to_string(), tx);
2256 }
2257
2258 LeaderAction::VaultSwitch => self.open_overlay(OverlayOpen::WorkspaceSwitcher, tx),
2260 LeaderAction::VaultReindex => {
2261 let vault = self.vault.clone();
2264 let tx2 = tx.clone();
2265 self.footer.flash("reindexing…".to_string(), tx);
2266 tokio::spawn(async move {
2267 let started = std::time::Instant::now();
2268 let result = vault.index_notes(kimun_core::NotesValidation::Fast).await;
2269 let msg = match result {
2270 Ok(_) => format!("reindexed in {:.1?}", started.elapsed()),
2271 Err(e) => format!("reindex failed: {e}"),
2272 };
2273 tx2.send(AppEvent::FlashMessage(msg)).ok();
2274 });
2275 }
2276 LeaderAction::VaultConfig => self.open_drawer_view(DrawerView::Config, tx),
2279 LeaderAction::VaultTheme => self.open_overlay(OverlayOpen::ThemePicker, tx),
2280 LeaderAction::VaultPreferences => {
2281 tx.send(AppEvent::OpenScreen(ScreenEvent::OpenPreferences))
2282 .ok();
2283 }
2284 LeaderAction::AppOnboarding => {
2285 tx.send(AppEvent::OpenScreen(ScreenEvent::OpenOnboarding))
2286 .ok();
2287 }
2288
2289 LeaderAction::WindowZen => {
2291 self.panels.hide(PanelKind::Drawer);
2292 self.focus_editor();
2293 }
2294 LeaderAction::WindowSplit => {
2295 self.footer
2296 .flash("editor splits — coming soon".to_string(), tx);
2297 }
2298 LeaderAction::WindowGrowDrawer => self.panels.adjust_drawer_width(4),
2299 LeaderAction::WindowShrinkDrawer => self.panels.adjust_drawer_width(-4),
2300
2301 LeaderAction::NoteToggleTodo => {
2303 self.footer
2304 .flash("toggle todo — coming soon".to_string(), tx);
2305 }
2306 LeaderAction::NotePreview => {
2307 self.footer
2308 .flash("preview — lands with phase 09".to_string(), tx);
2309 }
2310 LeaderAction::NoteCopyWikilink => {
2311 if let Some(path) = self.open_note_or_flash(tx) {
2312 let link = format!("[[{}]]", path.get_clean_name());
2313 crate::components::yank(link, "wikilink copied", tx);
2314 }
2315 }
2316 LeaderAction::NoteExport => {
2317 self.footer.flash("export — coming soon".to_string(), tx);
2318 }
2319 LeaderAction::NoteYankPath => {
2320 if let Some(path) = self.open_note_or_flash(tx) {
2321 crate::components::yank(path.to_string(), "note path copied", tx);
2322 }
2323 }
2324
2325 LeaderAction::TextBold => self.apply_text_from_leader(TextAction::Bold, tx),
2331 LeaderAction::TextItalic => self.apply_text_from_leader(TextAction::Italic, tx),
2332 LeaderAction::TextStrikethrough => {
2333 self.apply_text_from_leader(TextAction::Strikethrough, tx)
2334 }
2335
2336 LeaderAction::AskFocus => self.open_ask_workspace(tx),
2338 LeaderAction::AskNew => {
2339 self.with_ask_panel(tx, |panel, _tx| {
2340 panel.thread_mut().clear();
2341 panel.focus_composer();
2342 });
2343 self.panels.ask_sources_mut().reset(tx);
2344 }
2345 LeaderAction::AskCopy => {
2346 self.with_ask_panel(tx, |panel, tx| panel.copy_selected(tx));
2347 }
2348 LeaderAction::AskSave => {
2349 self.with_ask_panel(tx, |panel, tx| panel.save_selected(tx));
2350 }
2351 LeaderAction::AskRegenerate => {
2352 self.with_ask_panel(tx, |panel, tx| panel.regenerate_selected(tx));
2353 self.ask.sync_sources(&mut self.panels, tx);
2354 }
2355 LeaderAction::AskSource => {
2356 self.ensure_ask_workspace(tx);
2357 self.ask.sync_sources_from_selected(&mut self.panels, tx);
2360 self.panels.ask_sources_mut().open_reader(0, tx);
2361 self.panels.focus(PanelKind::Drawer);
2362 }
2363
2364 LeaderAction::Palette => self.open_overlay(OverlayOpen::CommandPalette, tx),
2365 LeaderAction::Help => self.open_overlay(OverlayOpen::Cheatsheet, tx),
2366
2367 LeaderAction::NoteSave => {
2368 self.spawn_autosave(tx);
2371 }
2372 LeaderAction::AppQuit => {
2373 tx.send(AppEvent::Quit).ok();
2374 }
2375
2376 LeaderAction::FindPinned => self.open_overlay(OverlayOpen::PinnedNotes, tx),
2378 LeaderAction::NoteTogglePin => self.toggle_pin_open_note(tx),
2379 LeaderAction::PinnedJump(n) => self.jump_to_pinned(n, tx),
2380 }
2381 }
2382
2383 fn open_find_with_query(&mut self, query: String, name: Option<String>, tx: &AppTx) {
2393 self.panels.open_drawer_view(DrawerView::Find);
2394 self.panels.query_mut().apply_query(query, name, tx.clone());
2395 self.panels.focus(PanelKind::Drawer);
2396 }
2397
2398 fn apply_saved_search(&mut self, query: String, name: String, tx: &AppTx) {
2399 self.open_find_with_query(query, Some(name), tx);
2404 }
2405
2406 fn toggle_backlinks(&mut self, tx: &AppTx) {
2407 if self.drawer_open_on(DrawerView::Find) {
2408 self.panels.hide(PanelKind::Drawer);
2409 } else {
2410 self.open_drawer_view(DrawerView::Find, tx);
2411 }
2412 }
2413}
2414
2415#[async_trait]
2416impl AppScreen for EditorScreen {
2417 fn get_kind(&self) -> ScreenKind {
2418 ScreenKind::Editor
2419 }
2420
2421 async fn on_enter(&mut self, tx: &AppTx) {
2422 self.app_tx = Some(tx.clone());
2423 self.open_path(self.path.clone(), None, tx).await;
2424 }
2425
2426 fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
2427 if let InputEvent::Mouse(m) = event {
2435 self.pointer = Some(ratatui::layout::Position::new(m.column, m.row));
2436 }
2437 self.overlays.observe_input(event);
2438 let double_click = self.track_click(event, std::time::Instant::now());
2439 let ctx = self.input_ctx_for(event, double_click);
2440 let classification = if matches!(event, InputEvent::Key(_)) {
2444 let s = self.settings.read().unwrap();
2445 classify(event, &s.key_bindings, &ctx)
2446 } else {
2447 classify(event, &KeyBindings::empty(), &ctx)
2448 };
2449 self.apply_classification(classification, event, tx)
2450 }
2451
2452 fn render(&mut self, f: &mut ratatui::Frame) {
2453 crate::components::clickable::clear();
2456 let theme = &self.theme;
2457 f.render_widget(
2458 ratatui::widgets::Block::default().style(theme.base_style()),
2459 f.area(),
2460 );
2461
2462 let rows = Layout::default()
2463 .direction(Direction::Vertical)
2464 .constraints([
2465 Constraint::Length(1),
2466 Constraint::Min(0),
2467 Constraint::Length(crate::components::footer_bar::STATUS_BAR_HEIGHT),
2468 ])
2469 .split(f.area());
2470
2471 let workspace_badge = {
2475 let s = self.settings.read().unwrap();
2476 s.workspace_config.as_ref().map(|wc| {
2477 ratatui::text::Line::from(vec![
2478 ratatui::text::Span::styled(
2479 self.icons.workspace,
2480 Style::default().fg(theme.accent.to_ratatui()),
2481 ),
2482 ratatui::text::Span::styled(
2483 format!(" {}", wc.global.current_workspace),
2484 Style::default().fg(theme.gray.to_ratatui()),
2485 ),
2486 ])
2487 })
2488 };
2489 const GAP: u16 = 3;
2494 let budget = rows[0].width / 2;
2495 let badge_w_full = workspace_badge
2496 .as_ref()
2497 .map(|b| b.width() as u16)
2498 .unwrap_or_default();
2499 let width_of = |palette: &str, badge: u16| {
2500 unicode_width::UnicodeWidthStr::width(palette) as u16
2501 + if badge > 0 { GAP + badge } else { 0 }
2502 + 2
2503 };
2504 let (palette_label, workspace_badge) = if width_of("≡ Commands", badge_w_full) <= budget {
2505 ("≡ Commands", workspace_badge)
2506 } else if width_of("≡", badge_w_full) <= budget {
2507 ("≡", workspace_badge)
2508 } else {
2509 ("≡", None)
2510 };
2511 let palette_w = unicode_width::UnicodeWidthStr::width(palette_label) as u16;
2512 let badge_w = workspace_badge
2513 .as_ref()
2514 .map(|b| b.width() as u16)
2515 .unwrap_or_default();
2516 let right_w = palette_w + if badge_w > 0 { GAP + badge_w } else { 0 };
2517 let breadcrumb = self
2518 .path
2519 .to_string()
2520 .trim_start_matches('/')
2521 .replace('/', " / ");
2522 let title_cols = Layout::default()
2523 .direction(Direction::Horizontal)
2524 .constraints([Constraint::Min(0), Constraint::Length(right_w + 2)])
2525 .split(rows[0]);
2526 f.render_widget(
2527 Paragraph::new(ratatui::text::Line::from(vec![
2528 ratatui::text::Span::styled(
2529 " Kimün ",
2530 Style::default()
2531 .fg(theme.accent.to_ratatui())
2532 .add_modifier(ratatui::style::Modifier::BOLD),
2533 ),
2534 ratatui::text::Span::styled(
2535 format!("─ {breadcrumb}"),
2536 Style::default().fg(theme.fg_secondary.to_ratatui()),
2537 ),
2538 ])),
2539 title_cols[0],
2540 );
2541 let right = Rect {
2544 x: title_cols[1].x + 2.min(title_cols[1].width),
2545 width: title_cols[1].width.saturating_sub(2),
2546 ..title_cols[1]
2547 };
2548 self.title_targets.clear();
2549 let mut target = |x: u16, w: u16, t: TitleTarget| {
2550 if x < right.right() {
2551 let w = w.min(right.right() - x);
2552 self.title_targets.push((Rect::new(x, right.y, w, 1), t));
2553 }
2554 };
2555 target(right.x, palette_w, TitleTarget::Palette);
2556 let mut spans = vec![ratatui::text::Span::styled(palette_label, theme.action())];
2557 if let Some(badge) = workspace_badge {
2558 target(right.x + palette_w + GAP, badge_w, TitleTarget::Workspace);
2559 spans.push(ratatui::text::Span::raw(" ".repeat(GAP as usize)));
2560 let mut badge_spans = badge.spans;
2562 if let Some(name) = badge_spans.last_mut() {
2563 name.style = theme.action();
2564 }
2565 spans.extend(badge_spans);
2566 }
2567 for (r, _) in &self.title_targets {
2568 crate::components::clickable::register(*r);
2569 }
2570 f.render_widget(Paragraph::new(ratatui::text::Line::from(spans)), right);
2571
2572 self.panels
2575 .render(f, rows[1], theme, !self.overlays.is_open());
2576
2577 let (focus_label, hints) = if let Some(kind) = self.overlays.active_kind() {
2581 (kind.label(), self.overlays.hint_shortcuts())
2582 } else {
2583 (self.panels.focused_label(), self.panels.focused_hints())
2584 };
2585 let editing = if let Some(kind) = self.overlays.active_kind() {
2586 !matches!(kind, OverlayKind::Dialog)
2589 } else {
2590 match self.panels.focused() {
2591 PanelKind::Editor => true,
2592 PanelKind::Drawer => self.panels.drawer_is_text_input(),
2593 PanelKind::Rail => false,
2594 }
2595 };
2596 let path_str = self.path.to_string();
2597 let link_segment = if self.panels.focused() == PanelKind::Editor && !self.overlays.is_open()
2600 {
2601 let link = self
2602 .panels
2603 .editor()
2604 .and_then(|e| e.follow_target_at_cursor());
2605 self.doc_meta
2606 .link_segment(link.as_ref(), &self.path, self.app_tx.as_ref())
2607 } else {
2608 None
2609 };
2610 let ln_col = (self.panels.focused() == PanelKind::Editor && !self.overlays.is_open())
2613 .then(|| {
2614 self.panels.editor().map(|e| {
2615 let (row, col) = e.cursor_pos();
2616 (row + 1, col + 1)
2617 })
2618 })
2619 .flatten();
2620 let matches = (self.panels.focused() == PanelKind::Drawer
2622 && self.panels.active_drawer_view() == DrawerView::Find)
2623 .then(|| self.panels.query().result_count());
2624 let (global_hints, leader_timeout, gateway_label) = {
2625 let s = self.settings.read().unwrap();
2626 (
2627 crate::components::hints::global_hints(&s.key_bindings),
2628 std::time::Duration::from_millis(s.leader_timeout_ms),
2629 s.key_bindings
2630 .first_combo_for(&ActionShortcuts::Leader)
2631 .unwrap_or_else(|| "leader".to_string()),
2632 )
2633 };
2634 let ctx = crate::components::footer_bar::StatusContext {
2635 focus_label,
2636 editing,
2637 hints: &hints,
2638 global_hints: &global_hints,
2639 doc: crate::components::footer_bar::DocState {
2640 path: &path_str,
2641 dirty: self.panels.editor().is_some_and(|e| e.is_dirty()),
2642 ln_col,
2643 backlinks: self.doc_meta.backlinks(),
2644 props: self.open_note().and(self.doc_meta.properties()),
2645 git: self.doc_meta.git().cloned(),
2646 matches,
2647 link: link_segment,
2648 update: self
2649 .update
2650 .as_ref()
2651 .map(|u| format!("⬆ {} available", u.latest)),
2652 rag: self.rag_status.label().map(|s| {
2653 use crate::server_client::sync::ServerUpdate;
2654 match &self.server_update {
2655 Some(ServerUpdate::Newer(v)) => format!("{s} · server {v} available"),
2656 Some(ServerUpdate::Legacy) => format!("{s} · server update available"),
2657 None => s.to_string(),
2658 }
2659 }),
2660 },
2661 };
2662 self.footer.render(f, rows[2], theme, &ctx);
2663
2664 let whichkey_visible = self
2667 .leader
2668 .pending_since()
2669 .is_some_and(|since| since.elapsed() >= leader_timeout);
2670 if whichkey_visible {
2671 let gateway = gateway_label;
2672 let area = f.area();
2673 let h = crate::components::which_key::desired_height(&self.leader, area.width)
2674 .min(rows[1].height);
2675 let rect =
2676 ratatui::layout::Rect::new(area.x, rows[2].y.saturating_sub(h), area.width, h);
2677 crate::components::clickable::occlude(rect);
2678 crate::components::which_key::render(f, rect, theme, &self.leader, &gateway);
2679 }
2680
2681 self.overlays.render(f, f.area(), &self.theme);
2683 crate::components::clickable::apply_hover(f.buffer_mut(), self.pointer, &self.theme);
2684 }
2685
2686 async fn handle_app_message(&mut self, msg: AppEvent, tx: &AppTx) {
2687 match msg {
2688 AppEvent::OverlayData(data) => {
2693 self.overlays.handle_data(&data, &self.vault, tx);
2694 }
2695 AppEvent::Update(flow) => self.handle_update(flow, tx),
2696 AppEvent::FileOp(op) => self.handle_file_op(op, tx).await,
2697 AppEvent::SavedSearch(flow) => self.handle_saved_search(flow, tx),
2698 msg => {
2699 let Some(msg) = self.doc_meta.handle(msg, &self.path) else {
2702 return;
2703 };
2704 self.handle_owned_message(msg, tx).await;
2705 }
2706 }
2707 }
2708
2709 async fn try_open_path(
2712 &mut self,
2713 path: VaultPath,
2714 emphasis: Option<Vec<String>>,
2715 tx: &AppTx,
2716 ) -> Option<VaultPath> {
2717 self.dismiss_overlay();
2718 if path.is_note() {
2719 self.open_path(path, emphasis, tx).await;
2720 self.focus_editor();
2721 } else {
2722 self.navigate_sidebar(path, tx);
2723 }
2724 None
2725 }
2726
2727 async fn try_open_attachment(&mut self, path: VaultPath, tx: &AppTx) -> Option<VaultPath> {
2728 self.dismiss_overlay();
2729 self.open_attachment(path, tx).await;
2730 None
2731 }
2732
2733 async fn on_exit(&mut self, _tx: &AppTx) {
2734 self.try_save().await;
2735 }
2736}
2737
2738#[cfg(test)]
2739mod tests {
2740 use super::*;
2741 use crate::keys::key_event_to_combo;
2742
2743 #[test]
2745 fn panel_kind_labels_and_overlay_kind_compile() {
2746 assert_eq!(PanelKind::Editor.label(), "EDITOR");
2747 assert_eq!(PanelKind::Rail.label(), "RAIL");
2748 assert_eq!(DrawerView::Files.label(), "FILES");
2749 assert_eq!(DrawerView::Find.label(), "FIND");
2750 let _kind = OverlayKind::Dialog;
2751 }
2752
2753 async fn test_screen() -> (
2756 EditorScreen,
2757 Arc<NoteVault>,
2758 SharedSettings,
2759 tempfile::TempDir,
2760 ) {
2761 use crate::settings::AppSettings;
2762 use kimun_core::VaultConfig;
2763 use std::sync::RwLock;
2764
2765 let dir = tempfile::TempDir::new().unwrap();
2766 let vault = Arc::new(
2767 NoteVault::new(VaultConfig::new(crate::test_support::sys(dir.path())))
2768 .await
2769 .unwrap(),
2770 );
2771 let settings: SharedSettings = Arc::new(RwLock::new(AppSettings::default()));
2772 let screen = EditorScreen::new(vault.clone(), VaultPath::root(), settings.clone());
2773 (screen, vault, settings, dir)
2774 }
2775
2776 async fn screen_on_note() -> (
2778 EditorScreen,
2779 Arc<NoteVault>,
2780 AppTx,
2781 tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
2782 tempfile::TempDir,
2783 ) {
2784 let (mut screen, vault, _settings, dir) = test_screen().await;
2785 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
2786 vault
2787 .create_note(&VaultPath::new("n.md"), "hi")
2788 .await
2789 .unwrap();
2790 screen.open_path(VaultPath::new("n.md"), None, &tx).await;
2791 (screen, vault, tx, rx, dir)
2792 }
2793
2794 #[tokio::test]
2795 async fn note_reload_from_disk_replaces_buffer_and_marks_clean() {
2796 let (mut screen, vault, tx, _rx, _dir) = screen_on_note().await;
2797 vault
2798 .save_note(&VaultPath::new("n.md"), "+++\na = 1\n+++\nbody")
2799 .await
2800 .unwrap();
2801 screen
2802 .handle_app_message(AppEvent::NoteReloadFromDisk(VaultPath::new("n.md")), &tx)
2803 .await;
2804 let ed = screen.panels.editor().unwrap();
2805 assert_eq!(ed.get_text(), "+++\na = 1\n+++\nbody");
2806 assert!(!ed.is_dirty());
2807 }
2808
2809 #[tokio::test]
2810 async fn note_reload_failure_flashes_instead_of_dropping() {
2811 let (mut screen, vault, tx, _rx, _dir) = screen_on_note().await;
2812 vault.delete_note(&VaultPath::new("n.md")).await.unwrap();
2813 screen
2814 .handle_app_message(AppEvent::NoteReloadFromDisk(VaultPath::new("n.md")), &tx)
2815 .await;
2816 assert_eq!(
2817 screen.footer.flash_text(),
2818 Some("reload failed — reopen the note before editing")
2819 );
2820 }
2821
2822 #[tokio::test]
2825 async fn note_reload_keeps_a_dirty_buffer_and_its_autosave() {
2826 let (mut screen, vault, tx, mut rx, _dir) = screen_on_note().await;
2827 screen.handle_input(&chr('x'), &tx);
2828 assert!(screen.panels.editor().unwrap().is_dirty(), "precondition");
2829 screen.autosave_task.spawn(std::future::pending::<()>());
2830 vault
2831 .save_note(&VaultPath::new("n.md"), "+++\na = 1\n+++\nhi")
2832 .await
2833 .unwrap();
2834 screen
2835 .handle_app_message(AppEvent::NoteReloadFromDisk(VaultPath::new("n.md")), &tx)
2836 .await;
2837 let ed = screen.panels.editor().unwrap();
2838 assert_eq!(ed.get_text(), "xhi", "the typing is kept");
2839 assert!(ed.is_dirty(), "still unsaved");
2840 assert!(
2841 screen.autosave_task.is_in_flight(),
2842 "the autosave carrying the typing is not aborted"
2843 );
2844 assert_eq!(
2845 screen.footer.flash_text(),
2846 Some("properties changed on disk — reopen the note to see them")
2847 );
2848 loop {
2851 let evt = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
2852 .await
2853 .expect("the disk count (1) is reported")
2854 .expect("open channel");
2855 if let AppEvent::PropertyCountLoaded { count: 1, .. } = evt {
2856 break;
2857 }
2858 }
2859 assert_eq!(screen.panels.editor().unwrap().get_text(), "xhi");
2860 screen.autosave_task.abort();
2861 }
2862
2863 async fn pump_until(
2866 screen: &mut EditorScreen,
2867 rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
2868 tx: &AppTx,
2869 mut done: impl FnMut(&AppEvent) -> bool,
2870 ) {
2871 let mut finished = false;
2872 while !finished {
2873 let evt = tokio::time::timeout(std::time::Duration::from_secs(5), rx.recv())
2874 .await
2875 .expect("timely event")
2876 .expect("open channel");
2877 finished = done(&evt);
2878 screen.handle_app_message(evt, tx).await;
2879 }
2880 }
2881
2882 #[tokio::test]
2885 async fn properties_dialog_set_reloads_the_editor_buffer() {
2886 use ratatui::crossterm::event::KeyCode;
2887 let (mut screen, vault, tx, mut rx, _dir) = screen_on_note().await;
2888 let path = VaultPath::new("n.md");
2889 screen
2890 .handle_file_op(FileOp::ShowProperties(path.clone()), &tx)
2891 .await;
2892 assert!(screen.overlays.is_open());
2893 pump_until(&mut screen, &mut rx, &tx, |e| {
2894 matches!(
2895 e,
2896 AppEvent::OverlayData(OverlayData::PropertiesLoaded { .. })
2897 )
2898 })
2899 .await;
2900 screen.handle_input(&chr('a'), &tx);
2901 for c in "priority".chars() {
2902 screen.handle_input(&chr(c), &tx);
2903 }
2904 screen.handle_input(&key_event(KeyCode::Tab), &tx);
2905 screen.handle_input(&key_event(KeyCode::Tab), &tx);
2906 screen.handle_input(&chr('2'), &tx);
2907 screen.handle_input(&key_event(KeyCode::Enter), &tx);
2908 let (mut reloaded, mut written) = (false, false);
2909 pump_until(&mut screen, &mut rx, &tx, |e| {
2910 match e {
2911 AppEvent::NoteReloadFromDisk(_) => reloaded = true,
2912 AppEvent::OverlayData(OverlayData::PropertyWritten { result, .. }) => {
2913 assert!(result.is_ok(), "{result:?}");
2914 written = true;
2915 }
2916 _ => {}
2917 }
2918 reloaded && written
2919 })
2920 .await;
2921 let text = vault.get_note_text(&path).await.unwrap();
2922 assert_eq!(
2923 vault.get_property(&path, "priority").await.unwrap(),
2924 Some(Some(kimun_core::note::PropertyValue::Number(2.0)))
2925 );
2926 let ed = screen.panels.editor().unwrap();
2927 assert_eq!(ed.get_text(), text);
2928 assert!(!ed.is_dirty());
2929 }
2930
2931 #[tokio::test]
2932 async fn properties_open_flushes_dirty_buffer() {
2933 let (mut screen, vault, tx, _rx, _dir) = screen_on_note().await;
2934 assert_eq!(screen.panels.focused(), PanelKind::Editor);
2936 screen.handle_input(&chr('x'), &tx);
2937 assert!(
2938 screen.panels.editor().unwrap().is_dirty(),
2939 "precondition: unsaved edit"
2940 );
2941 screen
2942 .handle_file_op(FileOp::ShowProperties(VaultPath::new("n.md")), &tx)
2943 .await;
2944 assert_eq!(
2945 vault.get_note_text(&VaultPath::new("n.md")).await.unwrap(),
2946 "xhi"
2947 );
2948 assert!(screen.overlays.is_open(), "dialog opened after a good save");
2949 }
2950
2951 fn key_event(code: ratatui::crossterm::event::KeyCode) -> InputEvent {
2952 use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
2953 InputEvent::Key(KeyEvent::new(code, KeyModifiers::NONE))
2954 }
2955
2956 fn ctrl_key(c: char) -> InputEvent {
2957 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2958 InputEvent::Key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL))
2959 }
2960
2961 fn chr(c: char) -> InputEvent {
2962 key_event(ratatui::crossterm::event::KeyCode::Char(c))
2963 }
2964
2965 #[tokio::test]
2968 async fn leader_sequence_opens_drawer_view() {
2969 let (mut screen, _, _, _dir) = test_screen().await;
2970 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2971
2972 screen.panels.open_drawer_view(DrawerView::Tags);
2974
2975 screen.handle_input(&ctrl_key('b'), &tx);
2976 assert!(screen.leader.is_pending());
2977 screen.handle_input(&chr('o'), &tx);
2978 screen.handle_input(&chr('f'), &tx);
2979
2980 assert!(!screen.leader.is_pending());
2981 assert_eq!(screen.panels.active_drawer_view(), DrawerView::Files);
2982 assert_eq!(screen.panels.focused(), PanelKind::Drawer);
2983 }
2984
2985 #[tokio::test]
2990 async fn leader_formatting_yields_to_the_find_bar() {
2991 let (mut screen, _, _, _dir) = test_screen().await;
2992 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2993 screen
2994 .panels
2995 .editor_mut()
2996 .unwrap()
2997 .set_text("plain".to_string());
2998 screen.run_op(EditorOp::FindInBuffer, &tx);
2999 assert_eq!(
3000 screen.panels.editor().unwrap().claim(),
3001 crate::components::text_editor::EditorClaim::FindBar
3002 );
3003
3004 screen.handle_input(&ctrl_key('b'), &tx);
3005 screen.handle_input(&chr('t'), &tx);
3006 screen.handle_input(&chr('b'), &tx);
3007
3008 assert!(!screen.leader.is_pending());
3009 let text = screen.panels.editor().unwrap().get_text();
3010 assert!(
3011 !text.contains("**"),
3012 "the note was formatted under a claim: {text:?}"
3013 );
3014 }
3015
3016 #[tokio::test]
3019 async fn leader_formatting_applies_with_the_editor_focused() {
3020 let (mut screen, _, _, _dir) = test_screen().await;
3021 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3022 screen
3023 .panels
3024 .editor_mut()
3025 .unwrap()
3026 .set_text("plain".to_string());
3027
3028 screen.handle_input(&ctrl_key('b'), &tx);
3029 screen.handle_input(&chr('t'), &tx);
3030 screen.handle_input(&chr('b'), &tx);
3031
3032 let text = screen.panels.editor().unwrap().get_text();
3033 assert!(text.contains("**"), "no bold marker was inserted: {text:?}");
3034 }
3035
3036 #[tokio::test]
3039 async fn opening_files_drawer_reveals_current_note_dir() {
3040 let (mut screen, _, _, _dir) = test_screen().await;
3041 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3042
3043 screen.path = VaultPath::new("projects").append(&VaultPath::note_path_from("plan"));
3044 screen
3046 .panels
3047 .sidebar_mut()
3048 .navigate(VaultPath::new("other"), &tx);
3049 screen.panels.hide(PanelKind::Drawer);
3050
3051 screen.open_drawer_view(DrawerView::Files, &tx);
3052
3053 assert!(
3054 screen
3055 .panels
3056 .sidebar()
3057 .current_dir()
3058 .is_like(&VaultPath::new("projects"))
3059 );
3060 }
3061
3062 #[tokio::test]
3066 async fn file_browser_shortcut_rereveals_note_dir_when_already_open() {
3067 let (mut screen, _, _, _dir) = test_screen().await;
3068 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3069
3070 screen.path = VaultPath::new("projects").append(&VaultPath::note_path_from("plan"));
3071 screen.open_drawer_view(DrawerView::Files, &tx);
3072 screen
3074 .panels
3075 .sidebar_mut()
3076 .navigate(VaultPath::new("other"), &tx);
3077
3078 screen.handle_input(&ctrl_key('e'), &tx);
3079
3080 assert!(
3081 screen
3082 .panels
3083 .sidebar()
3084 .current_dir()
3085 .is_like(&VaultPath::new("projects"))
3086 );
3087 }
3088
3089 #[tokio::test]
3092 async fn leader_consumes_keys_while_editor_focused() {
3093 let (mut screen, _, _, _dir) = test_screen().await;
3094 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3095
3096 screen.panels.editor_mut().unwrap().set_text(String::new());
3097 assert_eq!(screen.panels.focused(), PanelKind::Editor);
3098
3099 screen.handle_input(&ctrl_key('b'), &tx);
3100 assert!(screen.leader.is_pending());
3101 screen.handle_input(&chr('w'), &tx);
3103 screen.handle_input(&chr('z'), &tx); assert_eq!(screen.panels.editor().unwrap().get_text(), "");
3105 assert!(!screen.panels.is_visible(PanelKind::Drawer));
3106 }
3107
3108 #[tokio::test]
3110 async fn leader_esc_cancels_and_focuses_editor() {
3111 let (mut screen, _, _, _dir) = test_screen().await;
3112 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3113
3114 screen.panels.focus(PanelKind::Rail);
3115 screen.handle_input(&ctrl_key('b'), &tx);
3116 screen.handle_input(&chr('f'), &tx);
3117 screen.handle_input(&key_event(ratatui::crossterm::event::KeyCode::Esc), &tx);
3118
3119 assert!(!screen.leader.is_pending());
3120 assert_eq!(screen.panels.focused(), PanelKind::Editor);
3121 }
3122
3123 async fn wait_for(
3125 rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
3126 pred: impl Fn(&AppEvent) -> bool,
3127 ) -> AppEvent {
3128 tokio::time::timeout(std::time::Duration::from_secs(2), async {
3129 loop {
3130 let e = rx.recv().await.expect("channel open");
3131 if pred(&e) {
3132 break e;
3133 }
3134 }
3135 })
3136 .await
3137 .expect("expected event")
3138 }
3139
3140 #[tokio::test(flavor = "multi_thread")]
3143 async fn leader_f_p_opens_the_pinned_notes_dialog() {
3144 let (mut screen, _, _, _dir) = test_screen().await;
3145 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3146 screen.handle_input(&ctrl_key('b'), &tx);
3147 screen.handle_input(&chr('f'), &tx);
3148 screen.handle_input(&chr('p'), &tx);
3149 assert!(screen.overlays.is_open());
3150 assert_eq!(
3151 screen.overlays.active_kind(),
3152 Some(crate::components::overlay::OverlayKind::Dialog)
3153 );
3154 }
3155
3156 #[tokio::test(flavor = "multi_thread")]
3159 async fn leader_m_i_toggles_the_pin_and_flashes() {
3160 let (mut screen, vault, _, _dir) = test_screen().await;
3161 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3162 let note = VaultPath::new("plan.md");
3163 vault.create_note(¬e, "hi").await.unwrap();
3164 screen.path = note.clone();
3165
3166 screen.handle_input(&ctrl_key('b'), &tx);
3167 screen.handle_input(&chr('m'), &tx);
3168 screen.handle_input(&chr('i'), &tx);
3169 let e = wait_for(&mut rx, |e| matches!(e, AppEvent::FlashMessage(_))).await;
3170 assert!(
3171 matches!(&e, AppEvent::FlashMessage(m) if m == "pinned as 1 of 9"),
3172 "{e:?}"
3173 );
3174 assert_eq!(
3176 vault.list_pinned_notes().await.unwrap(),
3177 vec![VaultPath::new("/plan.md")]
3178 );
3179
3180 screen.handle_input(&ctrl_key('b'), &tx);
3181 screen.handle_input(&chr('m'), &tx);
3182 screen.handle_input(&chr('i'), &tx);
3183 let e = wait_for(&mut rx, |e| matches!(e, AppEvent::FlashMessage(_))).await;
3184 assert!(
3185 matches!(&e, AppEvent::FlashMessage(m) if m == "unpinned"),
3186 "{e:?}"
3187 );
3188 assert!(vault.list_pinned_notes().await.unwrap().is_empty());
3189 }
3190
3191 #[tokio::test(flavor = "multi_thread")]
3194 async fn leader_m_i_refuses_a_tenth_pin() {
3195 let (mut screen, vault, _, _dir) = test_screen().await;
3196 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3197 for i in 0..kimun_core::PINNED_NOTES_CAP {
3198 let n = VaultPath::new(format!("n{i}.md"));
3199 vault.create_note(&n, "hi").await.unwrap();
3200 vault.toggle_pinned_note(&n).await.unwrap();
3201 }
3202 let extra = VaultPath::new("extra.md");
3203 vault.create_note(&extra, "hi").await.unwrap();
3204 screen.path = extra;
3205 screen.handle_input(&ctrl_key('b'), &tx);
3206 screen.handle_input(&chr('m'), &tx);
3207 screen.handle_input(&chr('i'), &tx);
3208 let e = wait_for(&mut rx, |e| matches!(e, AppEvent::FlashMessage(_))).await;
3209 assert!(
3210 matches!(&e, AppEvent::FlashMessage(m) if m == "already 9 pinned — unpin one first"),
3211 "{e:?}"
3212 );
3213 }
3214
3215 #[tokio::test(flavor = "multi_thread")]
3219 async fn leader_m_i_on_a_missing_note_flashes() {
3220 let (mut screen, vault, _, _dir) = test_screen().await;
3221 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3222 screen.path = VaultPath::new("gone.md");
3223 screen.handle_input(&ctrl_key('b'), &tx);
3224 screen.handle_input(&chr('m'), &tx);
3225 screen.handle_input(&chr('i'), &tx);
3226 let e = wait_for(&mut rx, |e| matches!(e, AppEvent::FlashMessage(_))).await;
3227 assert!(
3228 matches!(&e, AppEvent::FlashMessage(m) if m == "note not found: gone.md"),
3229 "{e:?}"
3230 );
3231 assert!(vault.list_pinned_notes().await.unwrap().is_empty());
3232 }
3233
3234 #[tokio::test(flavor = "multi_thread")]
3238 async fn leader_m_c_with_ask_shown_flashes_like_m_i() {
3239 let (mut screen, vault, _, _dir) = test_screen().await;
3240 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3241 let note = VaultPath::new("plan.md");
3242 vault.create_note(¬e, "hi").await.unwrap();
3243 screen.path = note;
3244 screen.open_drawer_view(DrawerView::Ask, &tx);
3245
3246 screen.handle_input(&ctrl_key('b'), &tx);
3247 screen.handle_input(&chr('m'), &tx);
3248 screen.handle_input(&chr('c'), &tx);
3249 let e = wait_for(&mut rx, |e| matches!(e, AppEvent::FlashMessage(_))).await;
3250 assert!(
3251 matches!(&e, AppEvent::FlashMessage(m) if m == "no note open"),
3252 "{e:?}"
3253 );
3254 }
3255
3256 #[tokio::test(flavor = "multi_thread")]
3259 async fn leader_m_i_with_no_note_open_flashes() {
3260 let (mut screen, _, _, _dir) = test_screen().await;
3261 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3262 screen.handle_input(&ctrl_key('b'), &tx);
3264 screen.handle_input(&chr('m'), &tx);
3265 screen.handle_input(&chr('i'), &tx);
3266 let e = wait_for(&mut rx, |e| matches!(e, AppEvent::FlashMessage(_))).await;
3267 assert!(
3268 matches!(&e, AppEvent::FlashMessage(m) if m == "no note open"),
3269 "{e:?}"
3270 );
3271 }
3272
3273 #[tokio::test(flavor = "multi_thread")]
3276 async fn leader_m_i_with_attachment_shown_flashes() {
3277 let (mut screen, vault, _, _dir) = test_screen().await;
3278 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3279 vault
3280 .save_attachment(&VaultPath::new("assets/diagram.png"), &[1, 2, 3])
3281 .await
3282 .unwrap();
3283 screen
3284 .try_open_attachment(VaultPath::new("assets/diagram.png"), &tx)
3285 .await;
3286 assert!(screen.panels.is_showing_attachment());
3287
3288 screen.handle_input(&ctrl_key('b'), &tx);
3289 screen.handle_input(&chr('m'), &tx);
3290 screen.handle_input(&chr('i'), &tx);
3291 let e = wait_for(&mut rx, |e| matches!(e, AppEvent::FlashMessage(_))).await;
3292 assert!(
3293 matches!(&e, AppEvent::FlashMessage(m) if m == "no note open"),
3294 "{e:?}"
3295 );
3296 }
3297
3298 #[tokio::test(flavor = "multi_thread")]
3301 async fn jumping_from_the_outline_with_an_attachment_shown_flashes() {
3302 use crate::components::drawer_views::HeadingTarget;
3303 let (mut screen, vault, _, _dir) = test_screen().await;
3304 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3305 let note = VaultPath::note_path_from("alpha");
3306 vault.create_note(¬e, "# Alpha\n").await.unwrap();
3307 screen.open_path(note, None, &tx).await;
3308 screen.open_drawer_view(DrawerView::Outline, &tx);
3309 assert_eq!(screen.panels.outline_mut().headings_for_test(), ["Alpha"]);
3310 vault
3311 .save_attachment(&VaultPath::new("assets/diagram.png"), &[1, 2, 3])
3312 .await
3313 .unwrap();
3314 screen
3315 .try_open_attachment(VaultPath::new("assets/diagram.png"), &tx)
3316 .await;
3317
3318 let jump = AppEvent::JumpToHeading(HeadingTarget {
3319 text: "Alpha".to_string(),
3320 occurrence: 0,
3321 count: 1,
3322 line: 0,
3323 });
3324 screen.handle_app_message(jump, &tx).await;
3325 assert_eq!(screen.footer.flash_text(), Some("no note open"));
3326 assert!(screen.panels.outline_mut().headings_for_test().is_empty());
3327 }
3328
3329 #[tokio::test(flavor = "multi_thread")]
3333 async fn leader_m_i_with_ask_shown_flashes() {
3334 let (mut screen, vault, _, _dir) = test_screen().await;
3335 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3336 let note = VaultPath::new("plan.md");
3337 vault.create_note(¬e, "hi").await.unwrap();
3338 screen.path = note;
3339 screen.open_drawer_view(DrawerView::Ask, &tx);
3340 assert!(screen.panels.is_showing_ask());
3341
3342 screen.handle_input(&ctrl_key('b'), &tx);
3343 screen.handle_input(&chr('m'), &tx);
3344 screen.handle_input(&chr('i'), &tx);
3345 let e = wait_for(&mut rx, |e| matches!(e, AppEvent::FlashMessage(_))).await;
3346 assert!(
3347 matches!(&e, AppEvent::FlashMessage(m) if m == "no note open"),
3348 "{e:?}"
3349 );
3350 }
3351
3352 #[tokio::test(flavor = "multi_thread")]
3354 async fn leader_digit_opens_the_nth_pinned_note() {
3355 let (mut screen, vault, _, _dir) = test_screen().await;
3356 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3357 let a = VaultPath::new("a.md");
3358 let b = VaultPath::new("b.md");
3359 vault.create_note(&a, "a").await.unwrap();
3360 vault.create_note(&b, "b").await.unwrap();
3361 vault.toggle_pinned_note(&a).await.unwrap();
3362 vault.toggle_pinned_note(&b).await.unwrap();
3363
3364 screen.handle_input(&ctrl_key('b'), &tx);
3365 screen.handle_input(&chr('2'), &tx);
3366 let e = wait_for(&mut rx, |e| matches!(e, AppEvent::OpenPath { .. })).await;
3367 assert!(
3370 matches!(&e, AppEvent::OpenPath { path, .. } if path.is_like(&b)),
3371 "{e:?}"
3372 );
3373 }
3374
3375 #[tokio::test(flavor = "multi_thread")]
3377 async fn leader_digit_with_no_such_pin_flashes() {
3378 let (mut screen, _, _, _dir) = test_screen().await;
3379 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3380 screen.handle_input(&ctrl_key('b'), &tx);
3381 screen.handle_input(&chr('4'), &tx);
3382 let e = wait_for(&mut rx, |e| matches!(e, AppEvent::FlashMessage(_))).await;
3383 assert!(
3384 matches!(&e, AppEvent::FlashMessage(m) if m == "no pinned note 4"),
3385 "{e:?}"
3386 );
3387 }
3388
3389 #[tokio::test(flavor = "multi_thread")]
3392 async fn leader_digit_to_a_missing_pin_flashes() {
3393 let (mut screen, vault, _, dir) = test_screen().await;
3394 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3395 let gone = VaultPath::new("gone.md");
3396 vault.create_note(&gone, "hi").await.unwrap();
3397 vault.toggle_pinned_note(&gone).await.unwrap();
3398 std::fs::remove_file(dir.path().join("gone.md")).unwrap();
3400 screen.handle_input(&ctrl_key('b'), &tx);
3401 screen.handle_input(&chr('1'), &tx);
3402 let e = wait_for(&mut rx, |e| matches!(e, AppEvent::FlashMessage(_))).await;
3403 assert!(
3405 matches!(&e, AppEvent::FlashMessage(m) if m == "pinned note not found: /gone.md"),
3406 "{e:?}"
3407 );
3408 }
3409
3410 #[tokio::test]
3414 async fn space_never_leads() {
3415 let (mut screen, _, _, _dir) = test_screen().await;
3416 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3417
3418 screen.panels.editor_mut().unwrap().set_text(String::new());
3420 screen.handle_input(&chr(' '), &tx);
3421 assert!(!screen.leader.is_pending());
3422 assert_eq!(screen.panels.editor().unwrap().get_text(), " ");
3423
3424 screen.panels.focus(PanelKind::Rail);
3426 screen.handle_input(&chr(' '), &tx);
3427 assert!(!screen.leader.is_pending());
3428
3429 screen.panels.open_drawer_view(DrawerView::Find);
3431 screen.panels.focus(PanelKind::Drawer);
3432 screen.handle_input(&chr(' '), &tx);
3433 assert!(!screen.leader.is_pending());
3434 }
3435
3436 #[tokio::test]
3437 async fn persist_saved_search_writes_via_core() {
3438 let (screen, _, _, _dir) = test_screen().await;
3439
3440 screen.persist_saved_search("t", "#todo").await.unwrap();
3441
3442 let all = screen.vault.list_saved_searches().await.unwrap();
3443 assert!(all.iter().any(|s| s.name == "t" && s.query == "#todo"));
3444 }
3445
3446 #[tokio::test]
3447 async fn applying_saved_search_sets_panel_query_and_focuses_it() {
3448 let (mut screen, _, _, _dir) = test_screen().await;
3449
3450 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3451 screen.apply_saved_search(
3452 "<{note}".to_string(),
3453 "Backlinks (current note)".to_string(),
3454 &tx,
3455 );
3456 assert!(screen.panels.is_visible(PanelKind::Drawer));
3457 assert_eq!(screen.panels.active_drawer_view(), DrawerView::Find);
3458 assert_eq!(screen.panels.query().active_query(), "<{note}");
3459 assert_eq!(screen.panels.focused(), PanelKind::Drawer);
3460 }
3461
3462 #[tokio::test]
3463 async fn saved_search_persisted_repins_panel_breadcrumb() {
3464 let (mut screen, _, _, _dir) = test_screen().await;
3465
3466 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3467 screen.apply_saved_search("#todo".to_string(), "todo".to_string(), &tx);
3468 screen
3469 .panels
3470 .query_mut()
3471 .set_active_query("#todo and #urgent".to_string());
3472
3473 screen
3474 .handle_app_message(
3475 AppEvent::SavedSearch(SavedSearchFlow::Persisted {
3476 name: "urgent-todos".to_string(),
3477 query: "#todo and #urgent".to_string(),
3478 source: SaveSource::QueryPanel,
3479 }),
3480 &tx,
3481 )
3482 .await;
3483
3484 assert_eq!(
3485 screen.panels.query().saved_search_breadcrumb().as_deref(),
3486 Some("urgent-todos"),
3487 "a persisted panel-sourced save re-pins the breadcrumb"
3488 );
3489 }
3490
3491 #[tokio::test]
3492 async fn persisted_note_browser_save_does_not_repin_even_on_equal_query() {
3493 let (mut screen, _, _, _dir) = test_screen().await;
3494
3495 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3496 screen.apply_saved_search("#todo".to_string(), "todo".to_string(), &tx);
3497
3498 screen
3501 .handle_app_message(
3502 AppEvent::SavedSearch(SavedSearchFlow::Persisted {
3503 name: "inbox".to_string(),
3504 query: "#todo".to_string(),
3505 source: SaveSource::NoteBrowser,
3506 }),
3507 &tx,
3508 )
3509 .await;
3510
3511 assert_eq!(
3512 screen.panels.query().saved_search_breadcrumb().as_deref(),
3513 Some("todo"),
3514 "a note-browser save must not steal the panel's provenance"
3515 );
3516 }
3517
3518 #[tokio::test]
3519 async fn persisted_query_as_name_save_skips_repin() {
3520 let (mut screen, _, _, _dir) = test_screen().await;
3521
3522 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3523 screen.apply_saved_search("#todo".to_string(), "todo".to_string(), &tx);
3524
3525 screen
3529 .handle_app_message(
3530 AppEvent::SavedSearch(SavedSearchFlow::Persisted {
3531 name: "#todo".to_string(),
3532 query: "#todo".to_string(),
3533 source: SaveSource::QueryPanel,
3534 }),
3535 &tx,
3536 )
3537 .await;
3538
3539 assert_eq!(
3540 screen.panels.query().saved_search_breadcrumb().as_deref(),
3541 Some("todo"),
3542 "a query-as-name save leaves the breadcrumb alone"
3543 );
3544 }
3545
3546 #[tokio::test]
3547 async fn save_search_confirmed_persists_then_emits_persisted() {
3548 let (mut screen, vault, _, _dir) = test_screen().await;
3549
3550 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3551 screen
3552 .handle_app_message(
3553 AppEvent::SavedSearch(SavedSearchFlow::Confirmed {
3554 name: "mine".to_string(),
3555 query: "#todo".to_string(),
3556 source: SaveSource::QueryPanel,
3557 }),
3558 &tx,
3559 )
3560 .await;
3561
3562 let event = tokio::time::timeout(std::time::Duration::from_secs(5), async {
3564 loop {
3565 match rx.recv().await {
3566 Some(e @ AppEvent::SavedSearch(SavedSearchFlow::Persisted { .. })) => break e,
3567 Some(_) => continue,
3568 None => panic!("channel closed before SavedSearchPersisted"),
3569 }
3570 }
3571 })
3572 .await
3573 .expect("SavedSearchPersisted within timeout");
3574
3575 let AppEvent::SavedSearch(SavedSearchFlow::Persisted {
3576 name,
3577 query,
3578 source,
3579 }) = event
3580 else {
3581 unreachable!()
3582 };
3583 assert_eq!((name.as_str(), query.as_str()), ("mine", "#todo"));
3584 assert_eq!(source, SaveSource::QueryPanel);
3585 let all = vault.list_saved_searches().await.unwrap();
3586 assert!(all.iter().any(|s| s.name == "mine" && s.query == "#todo"));
3587 }
3588
3589 #[tokio::test]
3590 async fn save_query_source_carries_panel_provenance() {
3591 let (mut screen, _, _, _dir) = test_screen().await;
3592
3593 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3594 screen.apply_saved_search("#todo".to_string(), "todo".to_string(), &tx);
3595
3596 assert_eq!(
3597 screen.save_query_source(),
3598 Some((
3599 "#todo".to_string(),
3600 Some("todo".to_string()),
3601 SaveSource::QueryPanel
3602 )),
3603 "the save dialog opens pre-filled with the breadcrumb provenance"
3604 );
3605 }
3606
3607 #[tokio::test]
3613 async fn saved_search_selected_then_close_overlay_keeps_backlinks_focus() {
3614 use crate::settings::AppSettings;
3615 use kimun_core::VaultConfig;
3616 use std::sync::RwLock;
3617
3618 let dir = tempfile::TempDir::new().unwrap();
3619 let vault = Arc::new(
3620 NoteVault::new(VaultConfig::new(crate::test_support::sys(dir.path())))
3621 .await
3622 .unwrap(),
3623 );
3624 let settings: SharedSettings = Arc::new(RwLock::new(AppSettings::default()));
3625 let mut screen = EditorScreen::new(vault, VaultPath::root(), settings);
3626
3627 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3628 screen
3630 .handle_app_message(
3631 AppEvent::SavedSearch(SavedSearchFlow::Selected {
3632 query: "<{note}".to_string(),
3633 name: "Backlinks (current note)".to_string(),
3634 }),
3635 &tx,
3636 )
3637 .await;
3638 screen.handle_app_message(AppEvent::CloseOverlay, &tx).await;
3639
3640 assert!(
3641 screen.panels.focused() == PanelKind::Drawer
3642 && screen.panels.active_drawer_view() == DrawerView::Find,
3643 "focus should remain on the FIND drawer after select + close"
3644 );
3645 assert!(!screen.overlays.is_open(), "overlay should be closed");
3646 }
3647
3648 #[tokio::test]
3653 async fn opener_action_does_not_replace_open_overlay() {
3654 use crate::settings::AppSettings;
3655 use kimun_core::VaultConfig;
3656 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
3657 use std::sync::RwLock;
3658
3659 let dir = tempfile::TempDir::new().unwrap();
3660 let vault = Arc::new(
3661 NoteVault::new(VaultConfig::new(crate::test_support::sys(dir.path())))
3662 .await
3663 .unwrap(),
3664 );
3665 let settings: SharedSettings = Arc::new(RwLock::new(AppSettings::default()));
3666 let mut screen = EditorScreen::new(vault.clone(), VaultPath::root(), settings.clone());
3667
3668 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3669
3670 {
3673 let s = settings.read().unwrap();
3674 let modal = SavedSearchesModal::new(
3675 vault.clone(),
3676 s.key_bindings.clone(),
3677 s.icons(),
3678 tx.clone(),
3679 );
3680 drop(s);
3681 screen.overlays.open(Box::new(modal), screen.opener_focus());
3682 }
3683 assert_eq!(
3684 screen.overlays.active_kind(),
3685 Some(OverlayKind::SavedSearches),
3686 "precondition: SavedSearches overlay is active"
3687 );
3688
3689 let quick_note_event = KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL);
3693 {
3694 let s = settings.read().unwrap();
3695 let combo = key_event_to_combo(&quick_note_event).expect("Ctrl+W maps to a combo");
3696 assert_eq!(
3697 s.key_bindings.get_action(&combo),
3698 Some(ActionShortcuts::QuickNote),
3699 "test assumes Ctrl+W is bound to QuickNote"
3700 );
3701 }
3702
3703 screen.handle_input(&InputEvent::Key(quick_note_event), &tx);
3704
3705 assert_eq!(
3708 screen.overlays.active_kind(),
3709 Some(OverlayKind::SavedSearches),
3710 "open overlay must not be replaced by a QuickNote opener action"
3711 );
3712 }
3713
3714 #[tokio::test]
3718 async fn focus_action_is_noop_while_overlay_open() {
3719 use crate::settings::AppSettings;
3720 use kimun_core::VaultConfig;
3721 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
3722 use std::sync::RwLock;
3723
3724 let dir = tempfile::TempDir::new().unwrap();
3725 let vault = Arc::new(
3726 NoteVault::new(VaultConfig::new(crate::test_support::sys(dir.path())))
3727 .await
3728 .unwrap(),
3729 );
3730 let settings: SharedSettings = Arc::new(RwLock::new(AppSettings::default()));
3731 let mut screen = EditorScreen::new(vault.clone(), VaultPath::root(), settings.clone());
3732
3733 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3734
3735 {
3737 let s = settings.read().unwrap();
3738 let modal = SavedSearchesModal::new(
3739 vault.clone(),
3740 s.key_bindings.clone(),
3741 s.icons(),
3742 tx.clone(),
3743 );
3744 drop(s);
3745 screen.overlays.open(Box::new(modal), screen.opener_focus());
3746 }
3747 assert_ne!(screen.panels.active_drawer_view(), DrawerView::Find);
3748 let focused_before = screen.panels.focused();
3749
3750 let focus_right = KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL);
3752 {
3753 let s = settings.read().unwrap();
3754 let combo = key_event_to_combo(&focus_right).expect("Ctrl+L maps to a combo");
3755 assert_eq!(
3756 s.key_bindings.get_action(&combo),
3757 Some(ActionShortcuts::FocusEditor),
3758 "test assumes Ctrl+L is bound to FocusEditor"
3759 );
3760 }
3761 screen.handle_input(&InputEvent::Key(focus_right), &tx);
3762
3763 assert_eq!(
3764 screen.panels.focused(),
3765 focused_before,
3766 "focus action must not move focus while an overlay is open"
3767 );
3768 assert_ne!(
3769 screen.panels.active_drawer_view(),
3770 DrawerView::Find,
3771 "focus action must not switch the drawer view while an overlay is open"
3772 );
3773 assert_eq!(
3774 screen.overlays.active_kind(),
3775 Some(OverlayKind::SavedSearches),
3776 "overlay stays active"
3777 );
3778 }
3779
3780 #[tokio::test(flavor = "multi_thread")]
3785 async fn open_journal_dismisses_open_overlay() {
3786 let vault = crate::test_support::temp_vault("editor-journal").await;
3787 vault.validate_and_init().await.unwrap();
3788 let settings = std::sync::Arc::new(std::sync::RwLock::new(
3789 crate::settings::AppSettings::default(),
3790 ));
3791 let mut screen = EditorScreen::new(vault.clone(), VaultPath::root(), settings.clone());
3792 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3793
3794 {
3795 let s = settings.read().unwrap();
3796 let modal = SavedSearchesModal::new(
3797 vault.clone(),
3798 s.key_bindings.clone(),
3799 s.icons(),
3800 tx.clone(),
3801 );
3802 drop(s);
3803 screen.present_overlay(Box::new(modal));
3804 }
3805 assert!(screen.overlays.is_open(), "precondition: overlay open");
3806
3807 let (details, _, _) = vault.journal_entry().await.unwrap();
3808 screen.try_open_path(details.path, None, &tx).await;
3809
3810 assert!(
3811 !screen.overlays.is_open(),
3812 "opening the journal must dismiss the overlay before loading the note"
3813 );
3814 }
3815
3816 #[tokio::test(flavor = "multi_thread")]
3820 async fn opening_attachment_shows_attachment_view() {
3821 let vault = crate::test_support::temp_vault("editor-attachment").await;
3822 vault.validate_and_init().await.unwrap();
3823 vault
3824 .save_attachment(&VaultPath::new("assets/diagram.png"), &[1, 2, 3])
3825 .await
3826 .unwrap();
3827 let settings = std::sync::Arc::new(std::sync::RwLock::new(
3828 crate::settings::AppSettings::default(),
3829 ));
3830 let mut screen = EditorScreen::new(vault.clone(), VaultPath::root(), settings);
3831 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3832
3833 assert!(screen.panels.editor().is_some());
3835
3836 screen
3837 .try_open_attachment(VaultPath::new("assets/diagram.png"), &tx)
3838 .await;
3839
3840 assert!(
3841 screen.panels.is_showing_attachment(),
3842 "the editor area shows the attachment view"
3843 );
3844 assert!(
3845 screen.panels.editor().is_none(),
3846 "no note editor is mounted while an attachment is shown"
3847 );
3848 assert_eq!(
3849 screen.panels.attachment_path(),
3850 Some(&VaultPath::new("assets/diagram.png"))
3851 );
3852
3853 vault
3855 .create_note(&VaultPath::new("note.md"), "hi")
3856 .await
3857 .unwrap();
3858 screen.open_path(VaultPath::new("note.md"), None, &tx).await;
3859 assert!(!screen.panels.is_showing_attachment());
3860 assert!(screen.panels.editor().is_some());
3861 }
3862
3863 #[tokio::test]
3864 async fn save_query_from_note_browser_opens_save_dialog() {
3865 use crate::settings::AppSettings;
3866 use kimun_core::VaultConfig;
3867 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
3868 use std::sync::RwLock;
3869
3870 let dir = tempfile::TempDir::new().unwrap();
3871 let vault = Arc::new(
3872 NoteVault::new(VaultConfig::new(crate::test_support::sys(dir.path())))
3873 .await
3874 .unwrap(),
3875 );
3876 let settings: SharedSettings = Arc::new(RwLock::new(AppSettings::default()));
3877 let mut screen = EditorScreen::new(vault.clone(), VaultPath::root(), settings.clone());
3878
3879 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3880
3881 {
3883 let s = settings.read().unwrap();
3884 let provider = resolving_search_source(vault.clone(), s.current_last_paths(), None);
3885 let modal = NoteBrowserModal::with_initial_query(
3886 "Note Browser",
3887 BrowserScope::Query,
3888 provider,
3889 vault.clone(),
3890 s.key_bindings.clone(),
3891 s.icons(),
3892 tx.clone(),
3893 "#todo",
3894 );
3895 drop(s);
3896 screen.overlays.open(Box::new(modal), screen.opener_focus());
3897 }
3898 assert_eq!(
3899 screen.overlays.active_kind(),
3900 Some(OverlayKind::NoteBrowser),
3901 "precondition: note browser is active with a query"
3902 );
3903 assert_eq!(screen.overlays.active_query(), Some("#todo"));
3904
3905 let save_event = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL);
3908 {
3909 let s = settings.read().unwrap();
3910 let combo = key_event_to_combo(&save_event).expect("Ctrl+D maps to a combo");
3911 assert_eq!(
3912 s.key_bindings.get_action(&combo),
3913 Some(ActionShortcuts::SaveCurrentQuery),
3914 "test assumes Ctrl+D is bound to SaveCurrentQuery"
3915 );
3916 }
3917
3918 screen.handle_input(&InputEvent::Key(save_event), &tx);
3919
3920 assert_eq!(
3921 screen.overlays.active_kind(),
3922 Some(OverlayKind::Dialog),
3923 "saving from the note browser opens the save-search dialog"
3924 );
3925 }
3926
3927 #[tokio::test]
3930 async fn opening_a_missing_note_leaves_the_history_alone() {
3931 use crate::settings::AppSettings;
3932 use std::sync::RwLock;
3933
3934 let vault = crate::test_support::temp_vault("editor-missing").await;
3935 vault
3936 .create_note(&VaultPath::new("here.md"), "text")
3937 .await
3938 .unwrap();
3939 let history_dir = tempfile::TempDir::new().unwrap();
3940 let settings: SharedSettings = Arc::new(RwLock::new(AppSettings::for_test_workspace(
3941 "ws",
3942 vault.workspace_path(),
3943 crate::test_support::sys(history_dir.path()),
3944 )));
3945 let mut screen = EditorScreen::new(vault, VaultPath::root(), settings.clone());
3946 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3947
3948 screen.open_path(VaultPath::new("here.md"), None, &tx).await;
3949 screen
3950 .open_path(VaultPath::new("missing.md"), None, &tx)
3951 .await;
3952
3953 assert_eq!(
3954 settings.read().unwrap().current_last_paths(),
3955 vec![VaultPath::new("here.md")]
3956 );
3957 assert_eq!(
3958 screen.overlays.active_kind(),
3959 Some(OverlayKind::Dialog),
3960 "the create-note offer still stands for an explicit open"
3961 );
3962 }
3963
3964 #[tokio::test(flavor = "multi_thread")]
3967 async fn renaming_open_note_retargets_in_place() {
3968 let vault = crate::test_support::temp_vault("editor-rename").await;
3969 vault.validate_and_init().await.unwrap();
3970 let from = VaultPath::note_path_from("old");
3971 vault.create_note(&from, "# Old\n\nbody").await.unwrap();
3972 let settings = std::sync::Arc::new(std::sync::RwLock::new(
3973 crate::settings::AppSettings::default(),
3974 ));
3975 let mut screen = EditorScreen::new(vault.clone(), from.clone(), settings);
3976 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3977 screen.on_enter(&tx).await;
3978
3979 let to = VaultPath::note_path_from("new");
3980 vault.rename_note(&from, &to).await.unwrap();
3981 screen
3982 .handle_app_message(
3983 AppEvent::FileOp(FileOp::Renamed {
3984 from: from.clone(),
3985 to: to.clone(),
3986 }),
3987 &tx,
3988 )
3989 .await;
3990
3991 assert_eq!(screen.path, to, "editor retargets to the new path");
3992 assert!(
3993 !screen.panels.editor().unwrap().is_dirty(),
3994 "reloaded buffer is clean (won't clobber the renamed file)"
3995 );
3996 }
3997
3998 #[tokio::test(flavor = "multi_thread")]
4002 async fn outline_follows_the_buffer_at_save_and_reveal() {
4003 let vault = crate::test_support::temp_vault("editor-outline-buffer").await;
4004 vault.validate_and_init().await.unwrap();
4005 let path = VaultPath::note_path_from("alpha");
4006 vault.create_note(&path, "# Alpha\n").await.unwrap();
4007 let settings = std::sync::Arc::new(std::sync::RwLock::new(
4008 crate::settings::AppSettings::default(),
4009 ));
4010 let mut screen = EditorScreen::new(vault.clone(), path.clone(), settings);
4011 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4012 screen.on_enter(&tx).await;
4013 let headings = |screen: &mut EditorScreen| screen.panels.outline_mut().headings_for_test();
4014 let edit = |screen: &mut EditorScreen, text: &str| {
4015 let ed = screen.panels.editor_mut().unwrap();
4016 ed.set_text(text.to_string());
4017 ed.content_revision()
4018 };
4019 let saved = |path: &VaultPath, rev| AppEvent::AutosaveCompleted {
4020 path: path.clone(),
4021 saved_revision: rev,
4022 title: rev.map(|_| "Alpha".to_string()),
4023 };
4024
4025 screen.open_drawer_view(DrawerView::Outline, &tx);
4026 assert_eq!(headings(&mut screen), ["Alpha"]);
4027
4028 let rev = edit(&mut screen, "# Alpha\n## Beta\n");
4030 assert_eq!(headings(&mut screen), ["Alpha"]);
4031 screen
4032 .handle_app_message(saved(&path, Some(rev)), &tx)
4033 .await;
4034 assert_eq!(headings(&mut screen), ["Alpha", "Beta"]);
4035
4036 edit(&mut screen, "# Alpha\n## Beta\n## Gamma\n");
4038 screen.handle_app_message(saved(&path, None), &tx).await;
4039 assert_eq!(headings(&mut screen), ["Alpha", "Beta"]);
4040
4041 screen.open_drawer_view(DrawerView::Files, &tx);
4043 screen.open_drawer_view(DrawerView::Outline, &tx);
4044 assert_eq!(headings(&mut screen), ["Alpha", "Beta", "Gamma"]);
4045 }
4046
4047 #[tokio::test(flavor = "multi_thread")]
4050 async fn jumping_to_a_stale_outline_row_refreshes_the_outline() {
4051 use crate::components::drawer_views::HeadingTarget;
4052 let vault = crate::test_support::temp_vault("editor-outline-stale").await;
4053 vault.validate_and_init().await.unwrap();
4054 let path = VaultPath::note_path_from("alpha");
4055 vault.create_note(&path, "# Alpha\n## Foo\n").await.unwrap();
4056 let settings = std::sync::Arc::new(std::sync::RwLock::new(
4057 crate::settings::AppSettings::default(),
4058 ));
4059 let mut screen = EditorScreen::new(vault.clone(), path.clone(), settings);
4060 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4061 screen.on_enter(&tx).await;
4062 screen.open_drawer_view(DrawerView::Outline, &tx);
4063 assert_eq!(
4064 screen.panels.outline_mut().headings_for_test(),
4065 ["Alpha", "Foo"]
4066 );
4067
4068 screen
4069 .panels
4070 .editor_mut()
4071 .unwrap()
4072 .set_text("# Alpha\n## Bar\n".to_string());
4073 let jump = AppEvent::JumpToHeading(HeadingTarget {
4074 text: "Foo".to_string(),
4075 occurrence: 0,
4076 count: 1,
4077 line: 1,
4078 });
4079 screen.handle_app_message(jump, &tx).await;
4080
4081 assert_eq!(screen.panels.editor().unwrap().view_snapshot().cursor.0, 0);
4082 assert!(
4083 screen
4084 .footer
4085 .flash_text()
4086 .is_some_and(|t| t.contains("Foo"))
4087 );
4088 assert_eq!(
4089 screen.panels.outline_mut().headings_for_test(),
4090 ["Alpha", "Bar"]
4091 );
4092
4093 let jump = AppEvent::JumpToHeading(HeadingTarget {
4095 text: "Bar".to_string(),
4096 occurrence: 0,
4097 count: 1,
4098 line: 1,
4099 });
4100 screen.handle_app_message(jump, &tx).await;
4101 assert_eq!(screen.panels.editor().unwrap().view_snapshot().cursor.0, 1);
4102 }
4103
4104 #[tokio::test(flavor = "multi_thread")]
4107 async fn open_then_save_marks_and_retitles_sidebar_row() {
4108 let vault = crate::test_support::temp_vault("editor-marksave").await;
4109 vault.validate_and_init().await.unwrap();
4110 vault
4111 .create_note(&VaultPath::note_path_from("alpha"), "# Alpha\n\nbody")
4112 .await
4113 .unwrap();
4114 let settings = std::sync::Arc::new(std::sync::RwLock::new(
4115 crate::settings::AppSettings::default(),
4116 ));
4117 let path = VaultPath::note_path_from("alpha");
4118 let mut screen = EditorScreen::new(vault.clone(), path.clone(), settings);
4119 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4120 screen.on_enter(&tx).await;
4121 for _ in 0..50 {
4122 screen.panels.sidebar_mut().poll_for_test();
4123 if !screen.panels.sidebar().is_loading_for_test() {
4124 break;
4125 }
4126 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
4127 }
4128
4129 assert!(
4130 screen
4131 .panels
4132 .sidebar()
4133 .note_row_is_open_for_test("alpha.md"),
4134 "the open note's row is marked"
4135 );
4136
4137 screen
4138 .handle_app_message(
4139 AppEvent::AutosaveCompleted {
4140 path: path.clone(),
4141 saved_revision: None,
4142 title: Some("New First Line".to_string()),
4143 },
4144 &tx,
4145 )
4146 .await;
4147
4148 assert_eq!(
4149 screen.panels.sidebar().note_row_title_for_test("alpha.md"),
4150 Some("New First Line".to_string()),
4151 "the saved note's row title updated in place"
4152 );
4153 }
4154
4155 #[tokio::test]
4159 async fn ask_drawer_switch_preserves_thread() {
4160 let (mut screen, _, _, _dir) = test_screen().await;
4161 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4162
4163 screen.open_drawer_view(DrawerView::Ask, &tx);
4165 assert!(screen.panels.is_showing_ask());
4166 {
4167 let panel = screen.panels.ask_mut();
4168 panel.thread_mut().ask("first?".into());
4169 panel.thread_mut().ask("second?".into());
4170 }
4171 assert_eq!(screen.panels.ask().thread().turns().len(), 2);
4172
4173 screen.open_drawer_view(DrawerView::Files, &tx);
4176 assert!(!screen.panels.is_showing_ask());
4177 assert_eq!(screen.panels.ask().thread().turns().len(), 2);
4178
4179 screen.open_drawer_view(DrawerView::Ask, &tx);
4181 assert!(screen.panels.is_showing_ask());
4182 assert_eq!(
4183 screen.panels.ask().thread().turns().len(),
4184 2,
4185 "the conversation survives the round trip"
4186 );
4187 }
4188
4189 #[tokio::test]
4195 async fn ask_answer_ready_reaches_the_thread_via_the_owned_message_path() {
4196 let (mut screen, _, _, _dir) = test_screen().await;
4197 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4198
4199 screen.open_drawer_view(DrawerView::Ask, &tx);
4200 let id = screen.panels.ask_mut().thread_mut().ask("q?".into());
4201
4202 screen
4203 .handle_app_message(
4204 AppEvent::Ask(crate::components::events::AskData::AnswerReady {
4205 turn_id: id,
4206 result: Ok(("the answer".into(), vec![])),
4207 }),
4208 &tx,
4209 )
4210 .await;
4211
4212 let panel = screen.panels.ask_mut();
4213 let turn = panel.thread().selected().expect("a turn is selected");
4214 assert!(matches!(turn.status, crate::ask::TurnStatus::Done));
4215 assert_eq!(turn.answer, "the answer");
4216 }
4217
4218 #[tokio::test]
4223 async fn opening_a_note_over_ask_shows_the_note_and_keeps_the_thread() {
4224 let vault = crate::test_support::temp_vault("editor-ask-open").await;
4225 vault.validate_and_init().await.unwrap();
4226 vault
4227 .create_note(&VaultPath::note_path_from("alpha"), "# Alpha\n\nbody")
4228 .await
4229 .unwrap();
4230 let settings = std::sync::Arc::new(std::sync::RwLock::new(
4231 crate::settings::AppSettings::default(),
4232 ));
4233 let mut screen = EditorScreen::new(vault, VaultPath::root(), settings);
4234 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4235
4236 screen.open_drawer_view(DrawerView::Ask, &tx);
4237 screen
4238 .panels
4239 .ask_mut()
4240 .thread_mut()
4241 .ask("a question?".into());
4242 assert!(screen.panels.is_showing_ask());
4243
4244 screen
4246 .open_path(VaultPath::note_path_from("alpha"), None, &tx)
4247 .await;
4248 assert!(!screen.panels.is_showing_ask(), "the note replaced Ask");
4249 assert!(screen.panels.editor().is_some(), "the note editor is shown");
4250 assert_eq!(
4251 screen.panels.active_drawer_view(),
4252 DrawerView::Files,
4253 "the Sources drawer must not linger on a hidden conversation"
4254 );
4255
4256 screen.open_drawer_view(DrawerView::Ask, &tx);
4258 assert_eq!(
4259 screen.panels.ask().thread().turns().len(),
4260 1,
4261 "the thread survived opening the note"
4262 );
4263 }
4264
4265 #[tokio::test]
4271 async fn opening_a_note_over_ask_with_hidden_drawer_keeps_it_hidden() {
4272 let vault = crate::test_support::temp_vault("editor-ask-hidden-drawer").await;
4273 vault.validate_and_init().await.unwrap();
4274 vault
4275 .create_note(&VaultPath::note_path_from("alpha"), "# Alpha\n\nbody")
4276 .await
4277 .unwrap();
4278 let settings = std::sync::Arc::new(std::sync::RwLock::new(
4279 crate::settings::AppSettings::default(),
4280 ));
4281 let mut screen = EditorScreen::new(vault, VaultPath::root(), settings);
4282 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4283
4284 screen.open_drawer_view(DrawerView::Ask, &tx);
4286 screen.panels.hide(PanelKind::Drawer);
4287 assert!(!screen.panels.is_visible(PanelKind::Drawer));
4288 assert!(screen.panels.is_showing_ask());
4289
4290 screen
4293 .open_path(VaultPath::note_path_from("alpha"), None, &tx)
4294 .await;
4295 assert!(!screen.panels.is_showing_ask(), "the note replaced Ask");
4296 assert!(
4297 !screen.panels.is_visible(PanelKind::Drawer),
4298 "a hidden drawer must not be force-revealed"
4299 );
4300 assert_eq!(
4301 screen.panels.active_drawer_view(),
4302 DrawerView::Files,
4303 "the view switched off Ask to FILES"
4304 );
4305 }
4306
4307 #[tokio::test]
4315 async fn rail_click_reenters_ask_in_one_click_after_a_note_stashed_it() {
4316 let vault = crate::test_support::temp_vault("editor-ask-rail-reentry").await;
4317 vault.validate_and_init().await.unwrap();
4318 vault
4319 .create_note(&VaultPath::note_path_from("alpha"), "# Alpha\n\nbody")
4320 .await
4321 .unwrap();
4322 let settings = std::sync::Arc::new(std::sync::RwLock::new(
4323 crate::settings::AppSettings::default(),
4324 ));
4325 let mut screen = EditorScreen::new(vault, VaultPath::root(), settings);
4326 screen.rag_status = crate::rag::RagStatus::Online {
4327 llm_available: true,
4328 };
4329 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4330
4331 screen
4332 .handle_app_message(AppEvent::OpenDrawerView(DrawerView::Ask), &tx)
4333 .await;
4334 assert!(screen.panels.is_showing_ask());
4335 screen
4336 .panels
4337 .ask_mut()
4338 .thread_mut()
4339 .ask("a question?".into());
4340
4341 screen
4342 .open_path(VaultPath::note_path_from("alpha"), None, &tx)
4343 .await;
4344 assert!(!screen.panels.is_showing_ask());
4345
4346 screen
4348 .handle_app_message(AppEvent::OpenDrawerView(DrawerView::Ask), &tx)
4349 .await;
4350 assert!(
4351 screen.panels.is_showing_ask(),
4352 "a single reopen must not read as a toggle-off"
4353 );
4354 assert_eq!(
4355 screen.panels.ask().thread().turns().len(),
4356 1,
4357 "the thread survived the round trip"
4358 );
4359 }
4360
4361 #[tokio::test]
4367 async fn opening_an_attachment_over_ask_shows_it_and_keeps_the_thread() {
4368 let vault = crate::test_support::temp_vault("editor-ask-open-att").await;
4369 vault.validate_and_init().await.unwrap();
4370 let att_path = VaultPath::new("pic.png");
4371 vault
4372 .save_attachment(&att_path, b"\x89PNG\r\n\x1a\n")
4373 .await
4374 .unwrap();
4375 let settings = std::sync::Arc::new(std::sync::RwLock::new(
4376 crate::settings::AppSettings::default(),
4377 ));
4378 let mut screen = EditorScreen::new(vault, VaultPath::root(), settings);
4379 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4380
4381 screen.open_drawer_view(DrawerView::Ask, &tx);
4382 screen
4383 .panels
4384 .ask_mut()
4385 .thread_mut()
4386 .ask("a question?".into());
4387 assert!(screen.panels.is_showing_ask());
4388
4389 screen.open_attachment(att_path, &tx).await;
4391 assert!(
4392 !screen.panels.is_showing_ask(),
4393 "the attachment replaced Ask"
4394 );
4395
4396 screen.open_drawer_view(DrawerView::Ask, &tx);
4398 assert_eq!(
4399 screen.panels.ask().thread().turns().len(),
4400 1,
4401 "the thread survived opening the attachment"
4402 );
4403 }
4404
4405 #[tokio::test]
4409 async fn refresh_ask_capability_disables_without_a_client() {
4410 let (mut screen, _, _, _dir) = test_screen().await;
4411
4412 screen
4414 .ask
4415 .refresh_capability(&mut screen.panels, screen.rag_status)
4416 .await;
4417 assert!(
4418 !screen.panels.ask().has_client(),
4419 "the resident Ask panel loses its client without an LLM server"
4420 );
4421 }
4422
4423 #[tokio::test]
4426 async fn answer_lands_on_the_thread_while_browsing_files() {
4427 let (mut screen, _, _, _dir) = test_screen().await;
4428 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4429
4430 screen.open_drawer_view(DrawerView::Ask, &tx);
4431 let turn_id = screen.panels.ask_mut().thread_mut().ask("q?".into());
4432 screen.open_drawer_view(DrawerView::Files, &tx);
4434 assert!(!screen.panels.is_showing_ask());
4435
4436 screen.ask.handle_data(
4437 &mut screen.panels,
4438 crate::components::events::AskData::AnswerReady {
4439 turn_id,
4440 result: Ok(("the answer".into(), vec![])),
4441 },
4442 &tx,
4443 );
4444
4445 let thread = screen.panels.ask().thread();
4446 let turn = thread.turns().iter().find(|t| t.id == turn_id).unwrap();
4447 assert!(
4448 matches!(turn.status, crate::ask::TurnStatus::Done),
4449 "the answer completed the turn"
4450 );
4451 }
4452
4453 #[tokio::test]
4460 async fn footer_shows_server_update_next_to_rag_status() {
4461 use crate::server_client::sync::ServerUpdate;
4462 let (mut screen, _, _, _dir) = test_screen().await;
4463 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4464 let footer = |screen: &mut EditorScreen| {
4465 let mut term =
4466 ratatui::Terminal::new(ratatui::backend::TestBackend::new(160, 40)).unwrap();
4467 term.draw(|f| screen.render(f)).unwrap();
4468 let buf = term.backend().buffer();
4469 (0..160u16)
4470 .map(|x| buf[(x, 39)].symbol())
4471 .collect::<String>()
4472 };
4473
4474 screen
4475 .handle_owned_message(
4476 AppEvent::RagStatus(crate::rag::RagStatus::Online {
4477 llm_available: false,
4478 }),
4479 &tx,
4480 )
4481 .await;
4482 screen
4483 .handle_owned_message(
4484 AppEvent::ServerUpdate(Some(ServerUpdate::Newer("9.9.9".into()))),
4485 &tx,
4486 )
4487 .await;
4488 assert!(footer(&mut screen).contains("server 9.9.9 available"));
4489
4490 screen
4491 .handle_owned_message(AppEvent::ServerUpdate(Some(ServerUpdate::Legacy)), &tx)
4492 .await;
4493 assert!(footer(&mut screen).contains("server update available"));
4494
4495 screen
4496 .handle_owned_message(AppEvent::ServerUpdate(None), &tx)
4497 .await;
4498 let cleared = footer(&mut screen);
4499 assert!(cleared.contains("rag: online"), "rag status stays");
4500 assert!(
4501 !cleared.contains("server"),
4502 "the whole hint is gone: {cleared}"
4503 );
4504 }
4505
4506 #[tokio::test]
4507 async fn sem_rail_entry_tracks_search_availability() {
4508 let (mut screen, _, _, _dir) = test_screen().await;
4509 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4510
4511 assert!(!screen.panels.rail_shows(DrawerView::Semantic));
4513 assert!(!screen.panels.rail_shows(DrawerView::Ask));
4514
4515 screen
4517 .handle_owned_message(
4518 AppEvent::RagStatus(crate::rag::RagStatus::Online {
4519 llm_available: false,
4520 }),
4521 &tx,
4522 )
4523 .await;
4524 assert!(screen.panels.rail_shows(DrawerView::Semantic));
4525 assert!(!screen.panels.rail_shows(DrawerView::Ask));
4526
4527 screen
4529 .handle_owned_message(AppEvent::RagStatus(crate::rag::RagStatus::Offline), &tx)
4530 .await;
4531 assert!(!screen.panels.rail_shows(DrawerView::Semantic));
4532
4533 screen
4535 .handle_owned_message(
4536 AppEvent::RagStatus(crate::rag::RagStatus::Online {
4537 llm_available: true,
4538 }),
4539 &tx,
4540 )
4541 .await;
4542 assert!(screen.panels.rail_shows(DrawerView::Semantic));
4543 assert!(screen.panels.rail_shows(DrawerView::Ask));
4544 }
4545
4546 #[tokio::test]
4552 async fn ask_focus_skips_the_llm_gate_when_already_showing_ask() {
4553 let (mut screen, _, _, _dir) = test_screen().await;
4554 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4555
4556 screen.rag_status = crate::rag::RagStatus::Online {
4557 llm_available: true,
4558 };
4559 screen.open_ask_workspace(&tx);
4560 assert!(screen.panels.is_showing_ask());
4561
4562 screen.rag_status = crate::rag::RagStatus::Offline;
4565
4566 screen.open_ask_workspace(&tx);
4567 assert!(screen.panels.is_showing_ask(), "still showing Ask");
4568 assert_eq!(screen.panels.focused(), PanelKind::Editor);
4569 assert!(
4570 rx.try_recv().is_err(),
4571 "no gate flash while Ask is already showing"
4572 );
4573 }
4574
4575 fn lay_out(screen: &mut EditorScreen) {
4587 let mut term = ratatui::Terminal::new(ratatui::backend::TestBackend::new(120, 40)).unwrap();
4588 term.draw(|f| screen.render(f)).unwrap();
4589 }
4590
4591 fn buffer_origin(screen: &EditorScreen) -> (u16, u16) {
4594 (0..40u16)
4595 .flat_map(|row| (0..120u16).map(move |col| (col, row)))
4596 .find(|(col, row)| screen.panels.is_note_buffer_cell(*col, *row))
4597 .expect("a laid-out screen always shows the note buffer")
4598 }
4599
4600 fn frame_corner(screen: &mut EditorScreen, tx: &AppTx) -> (u16, u16) {
4611 let (col, row) = buffer_origin(screen);
4612 let corner = (col - 1, row - 1);
4613 screen.panels.focus(PanelKind::Drawer);
4614 screen.handle_input(&press_at(corner.0, corner.1), tx);
4615 assert_eq!(
4616 screen.panels.focused(),
4617 PanelKind::Editor,
4618 "precondition: the frame is inside the editor column"
4619 );
4620 screen.clicks.end();
4622 corner
4623 }
4624
4625 use crate::test_support::mouse_down_at as press_at;
4626
4627 #[tokio::test]
4628 async fn clicking_props_segment_opens_properties() {
4629 let (mut screen, _vault, tx, mut rx, _dir) = screen_on_note().await;
4630 screen
4632 .handle_app_message(
4633 AppEvent::PropertyCountLoaded {
4634 path: VaultPath::new("n.md"),
4635 count: 0,
4636 },
4637 &tx,
4638 )
4639 .await;
4640 lay_out(&mut screen);
4641 let (col, row) = (0..40u16)
4642 .flat_map(|r| (0..120u16).map(move |c| (c, r)))
4643 .find(|(c, r)| screen.footer.target_at(*c, *r) == Some(FooterTarget::Props))
4644 .expect("props segment laid out");
4645 while rx.try_recv().is_ok() {}
4646 screen.handle_input(&press_at(col, row), &tx);
4647 let mut opened = false;
4648 while let Ok(evt) = rx.try_recv() {
4649 opened |= matches!(evt, AppEvent::FileOp(FileOp::ShowProperties(_)));
4650 }
4651 assert!(opened);
4652 }
4653
4654 #[tokio::test]
4657 async fn clicking_update_segment_opens_update_dialog() {
4658 let (mut screen, _vault, tx, mut rx, _dir) = screen_on_note().await;
4659 screen.update = Some(crate::update::UpdateStatus {
4660 current: "0.1.0".into(),
4661 latest: "9.9.9".into(),
4662 channel: crate::update::InstallChannel::Cargo,
4663 update_available: true,
4664 dismissed: false,
4665 });
4666 lay_out(&mut screen);
4667 let (col, row) = (0..40u16)
4668 .flat_map(|r| (0..120u16).map(move |c| (c, r)))
4669 .find(|(c, r)| screen.footer.target_at(*c, *r) == Some(FooterTarget::Update))
4670 .expect("update segment laid out");
4671 while rx.try_recv().is_ok() {}
4672 screen.handle_input(&press_at(col, row), &tx);
4673 let mut shown = false;
4674 while let Ok(evt) = rx.try_recv() {
4675 shown |= matches!(evt, AppEvent::Update(UpdateFlow::ShowDialog));
4676 }
4677 assert!(shown);
4678 }
4679
4680 #[tokio::test]
4682 async fn clicking_backlinks_segment_opens_links() {
4683 let (mut screen, _vault, tx, _rx, _dir) = screen_on_note().await;
4684 screen
4685 .handle_app_message(
4686 AppEvent::BacklinkCountLoaded {
4687 path: VaultPath::new("n.md"),
4688 count: 2,
4689 },
4690 &tx,
4691 )
4692 .await;
4693 lay_out(&mut screen);
4694 let (col, row) = (0..40u16)
4695 .flat_map(|r| (0..120u16).map(move |c| (c, r)))
4696 .find(|(c, r)| screen.footer.target_at(*c, *r) == Some(FooterTarget::Backlinks))
4697 .expect("backlinks segment laid out");
4698 screen.handle_input(&press_at(col, row), &tx);
4699 assert_eq!(screen.panels.active_drawer_view(), DrawerView::Links);
4700 }
4701
4702 #[tokio::test]
4705 async fn find_bar_does_not_swallow_footer_clicks() {
4706 let (mut screen, _vault, tx, mut rx, _dir) = screen_on_note().await;
4707 screen
4708 .handle_app_message(
4709 AppEvent::PropertyCountLoaded {
4710 path: VaultPath::new("n.md"),
4711 count: 0,
4712 },
4713 &tx,
4714 )
4715 .await;
4716 screen.run_op(EditorOp::FindInBuffer, &tx);
4717 assert_eq!(
4718 screen.panels.editor().map(|e| e.claim()),
4719 Some(crate::components::text_editor::EditorClaim::FindBar)
4720 );
4721 lay_out(&mut screen);
4722 let (col, row) = (0..40u16)
4723 .flat_map(|r| (0..120u16).map(move |c| (c, r)))
4724 .find(|(c, r)| screen.footer.target_at(*c, *r) == Some(FooterTarget::Props))
4725 .expect("props segment laid out");
4726 while rx.try_recv().is_ok() {}
4727 screen.handle_input(&press_at(col, row), &tx);
4728 let mut opened = false;
4729 while let Ok(evt) = rx.try_recv() {
4730 opened |= matches!(evt, AppEvent::FileOp(FileOp::ShowProperties(_)));
4731 }
4732 assert!(opened);
4733 }
4734
4735 use crate::test_support::mouse_moved_at as moved_at;
4736
4737 fn at(millis: u64) -> std::time::Instant {
4739 static ORIGIN: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
4740 *ORIGIN.get_or_init(std::time::Instant::now) + std::time::Duration::from_millis(millis)
4741 }
4742
4743 #[tokio::test]
4744 async fn two_presses_on_the_buffer_are_a_double_click() {
4745 let (mut screen, _, _, _dir) = test_screen().await;
4746 lay_out(&mut screen);
4747 let (col, row) = buffer_origin(&screen);
4748
4749 assert!(!screen.track_click(&press_at(col, row), at(0)));
4750 assert!(screen.track_click(&press_at(col, row), at(120)));
4751 }
4752
4753 #[tokio::test]
4759 async fn a_press_on_the_editor_frame_is_never_half_of_a_double() {
4760 let (mut screen, _, _, _dir) = test_screen().await;
4761 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4762 lay_out(&mut screen);
4763 let (col, row) = frame_corner(&mut screen, &tx);
4764
4765 assert!(!screen.track_click(&press_at(col, row), at(0)));
4766 assert!(
4767 !screen.track_click(&press_at(col, row), at(120)),
4768 "two clicks on the frame are not a follow"
4769 );
4770 }
4771
4772 #[tokio::test]
4776 async fn a_frame_press_separates_two_buffer_presses() {
4777 let (mut screen, _, _, _dir) = test_screen().await;
4778 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4779 lay_out(&mut screen);
4780 let (frame_col, frame_row) = frame_corner(&mut screen, &tx);
4781 let (col, row) = buffer_origin(&screen);
4782
4783 screen.track_click(&press_at(col, row), at(0));
4784 screen.track_click(&press_at(frame_col, frame_row), at(40));
4785 assert!(!screen.track_click(&press_at(col, row), at(80)));
4786 }
4787
4788 #[tokio::test]
4792 async fn motion_over_the_frame_does_not_break_a_double() {
4793 let (mut screen, _, _, _dir) = test_screen().await;
4794 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4795 lay_out(&mut screen);
4796 let (frame_col, frame_row) = frame_corner(&mut screen, &tx);
4797 let (col, row) = buffer_origin(&screen);
4798
4799 screen.track_click(&press_at(col, row), at(0));
4800 assert!(!screen.track_click(&moved_at(frame_col, frame_row), at(40)));
4801 assert!(screen.track_click(&press_at(col, row), at(120)));
4802 }
4803
4804 #[tokio::test]
4805 async fn a_key_between_presses_ends_the_click_run() {
4806 let (mut screen, _, _, _dir) = test_screen().await;
4807 lay_out(&mut screen);
4808 let (col, row) = buffer_origin(&screen);
4809
4810 screen.track_click(&press_at(col, row), at(0));
4811 assert!(!screen.track_click(&chr('x'), at(40)));
4812 assert!(!screen.track_click(&press_at(col, row), at(80)));
4813 }
4814
4815 #[tokio::test]
4820 async fn an_overlay_ends_the_click_run() {
4821 let (mut screen, _, _, _dir) = test_screen().await;
4822 lay_out(&mut screen);
4823 let (col, row) = buffer_origin(&screen);
4824 let vault = screen.vault.clone();
4825
4826 screen.track_click(&press_at(col, row), at(0));
4827 screen.present_overlay(Box::new(ActiveDialog::create_note(
4828 VaultPath::note_path_from("x"),
4829 vault,
4830 None,
4831 )));
4832 assert!(
4833 !screen.track_click(&press_at(col, row), at(40)),
4834 "a press under an overlay is the overlay's"
4835 );
4836 screen.dismiss_overlay();
4837 assert!(
4838 !screen.track_click(&press_at(col, row), at(80)),
4839 "and neither press survives to pair with the next one"
4840 );
4841 }
4842
4843 #[tokio::test]
4847 async fn a_double_click_on_a_wikilink_follows_it() {
4848 let (mut screen, _, _, _dir) = test_screen().await;
4849 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4850 screen
4851 .panels
4852 .editor_mut()
4853 .unwrap()
4854 .set_text("[[target note]]".to_string());
4855 lay_out(&mut screen);
4856 let (col, row) = buffer_origin(&screen);
4859 let (col, row) = (col + 6, row);
4860
4861 screen.handle_input(&press_at(col, row), &tx);
4862 screen.handle_input(&press_at(col, row), &tx);
4863
4864 let followed = crate::test_support::drain(&mut rx)
4865 .into_iter()
4866 .find_map(|ev| match ev {
4867 AppEvent::FollowLink(target) => Some(target),
4868 _ => None,
4869 })
4870 .expect("the double-click emitted no FollowLink");
4871 assert_eq!(followed, "target note");
4872 }
4873
4874 #[tokio::test]
4877 async fn a_single_press_on_a_wikilink_only_places_the_cursor() {
4878 let (mut screen, _, _, _dir) = test_screen().await;
4879 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4880 screen
4881 .panels
4882 .editor_mut()
4883 .unwrap()
4884 .set_text("[[target note]]".to_string());
4885 lay_out(&mut screen);
4886 let (col, row) = buffer_origin(&screen);
4887
4888 screen.handle_input(&press_at(col + 6, row), &tx);
4889
4890 assert!(
4891 !crate::test_support::drain(&mut rx)
4892 .into_iter()
4893 .any(|ev| matches!(ev, AppEvent::FollowLink(_))),
4894 "one click is a cursor placement, not a navigation"
4895 );
4896 assert!(
4897 screen
4898 .panels
4899 .editor()
4900 .unwrap()
4901 .follow_target_at_cursor()
4902 .is_some(),
4903 "but it did land on the link, so the next press has something to follow"
4904 );
4905 }
4906}
4907
4908#[cfg(test)]
4909mod sort_routing_tests {
4910 use super::*;
4911 use crate::app_screen::AppScreen;
4912 use crate::components::events::SortTarget;
4913 use crate::components::file_list::SortOrder;
4914 use crate::components::sortable::SortState;
4915
4916 async fn make_editor() -> (
4917 EditorScreen,
4918 AppTx,
4919 tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
4920 ) {
4921 let vault = crate::test_support::temp_vault("editor-sort").await;
4922 vault.validate_and_init().await.unwrap();
4923 let settings = std::sync::Arc::new(std::sync::RwLock::new(
4924 crate::settings::AppSettings::default(),
4925 ));
4926 let screen = EditorScreen::new(vault, VaultPath::root(), settings);
4927 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
4928 (screen, tx, rx)
4929 }
4930
4931 fn find_drawn(screen: &mut EditorScreen, text: &str) -> Option<(u16, u16)> {
4934 let mut term = ratatui::Terminal::new(ratatui::backend::TestBackend::new(120, 40)).unwrap();
4935 term.draw(|f| screen.render(f)).unwrap();
4936 crate::test_support::find_text(term.backend().buffer(), text)
4937 }
4938
4939 #[tokio::test(flavor = "multi_thread")]
4942 async fn clicking_the_sort_chip_opens_the_sort_dialog() {
4943 let (mut screen, tx, mut rx) = make_editor().await;
4944 screen.panels.open_drawer_view(DrawerView::Files);
4945 let (col, row) = find_drawn(&mut screen, "Name ↑").expect("FILES draws its sort chip");
4946 let (_, search_row) = find_drawn(&mut screen, " Search").expect("search box drawn");
4947 assert_eq!(
4948 row, search_row,
4949 "the chip sits on the search box, not the header"
4950 );
4951 while rx.try_recv().is_ok() {}
4952 screen.handle_input(&crate::test_support::mouse_down_at(col, row), &tx);
4953 let mut target = None;
4954 while let Ok(e) = rx.try_recv() {
4955 if let AppEvent::OpenSortDialog(t) = e {
4956 target = Some(t);
4957 }
4958 }
4959 assert_eq!(target, Some(SortTarget::Sidebar));
4960 screen
4961 .handle_app_message(AppEvent::OpenSortDialog(SortTarget::Sidebar), &tx)
4962 .await;
4963 assert_eq!(screen.overlays.active_kind(), Some(OverlayKind::Dialog));
4964 }
4965
4966 fn drawn_buffer(screen: &mut EditorScreen) -> ratatui::buffer::Buffer {
4967 let mut term = ratatui::Terminal::new(ratatui::backend::TestBackend::new(120, 40)).unwrap();
4968 term.draw(|f| screen.render(f)).unwrap();
4969 term.backend().buffer().clone()
4970 }
4971
4972 use crate::test_support::mouse_moved_at as moved_to;
4973
4974 #[tokio::test(flavor = "multi_thread")]
4977 async fn hovering_a_click_target_highlights_it() {
4978 let (mut screen, tx, _rx) = make_editor().await;
4979 let hover = screen.theme.hover().bg.unwrap();
4980 let (col, row) = find_drawn(&mut screen, "≡ Commands").unwrap();
4981 screen.handle_input(&moved_to(col + 2, row), &tx);
4982 let buf = drawn_buffer(&mut screen);
4983 assert_eq!(buf[(col, row)].bg, hover, "the whole target lights up");
4984 assert_eq!(buf[(col + 9, row)].bg, hover);
4985 assert_ne!(
4986 buf[(col.saturating_sub(2), row)].bg,
4987 hover,
4988 "only the target"
4989 );
4990
4991 screen.handle_input(&moved_to(0, 20), &tx);
4992 let buf = drawn_buffer(&mut screen);
4993 assert_ne!(buf[(col, row)].bg, hover, "hover follows the pointer");
4994 }
4995
4996 #[tokio::test(flavor = "multi_thread")]
4999 async fn an_overlay_hides_hover_on_what_it_covers() {
5000 let (mut screen, tx, _rx) = make_editor().await;
5001 let hover = screen.theme.hover().bg.unwrap();
5002 let (col, row) = find_drawn(&mut screen, "≡ Commands").unwrap();
5003 screen.open_overlay(OverlayOpen::Cheatsheet, &tx);
5004 screen.handle_input(&moved_to(col + 2, row), &tx);
5005 let buf = drawn_buffer(&mut screen);
5006 assert_ne!(buf[(col, row)].bg, hover, "panels are inert under a modal");
5007
5008 let (ccol, crow) = find_drawn(&mut screen, "[Esc] Close").unwrap();
5009 screen.handle_input(&moved_to(ccol + 1, crow), &tx);
5011 let buf = drawn_buffer(&mut screen);
5012 assert_eq!(buf[(ccol, crow)].bg, hover, "the dialog's chip lights up");
5013 }
5014
5015 #[tokio::test(flavor = "multi_thread")]
5018 async fn sort_chip_stays_clickable_beside_a_parse_error() {
5019 let (mut screen, tx, mut rx) = make_editor().await;
5020 screen.panels.open_drawer_view(DrawerView::Find);
5021 screen
5022 .handle_app_message(AppEvent::RunTagQuery("x".into()), &tx)
5023 .await;
5024 screen
5025 .panels
5026 .query_mut()
5027 .set_active_query(r#"#x "open"#.to_string());
5028 assert!(
5029 find_drawn(&mut screen, "⚠").is_some(),
5030 "error segment drawn"
5031 );
5032 let (col, row) = find_drawn(&mut screen, "Name ↑").expect("chip still drawn");
5033 let (ecol, erow) = find_drawn(&mut screen, "⚠").unwrap();
5034 assert_eq!(row, erow);
5035 assert!(col > ecol, "the chip is the rightmost segment");
5036 while rx.try_recv().is_ok() {}
5037 screen.handle_input(&crate::test_support::mouse_down_at(col, row), &tx);
5038 assert!(
5039 crate::test_support::drain(&mut rx)
5040 .into_iter()
5041 .any(|e| matches!(e, AppEvent::OpenSortDialog(SortTarget::Query)))
5042 );
5043 }
5044
5045 #[tokio::test(flavor = "multi_thread")]
5048 async fn a_narrow_title_bar_keeps_room_for_the_path() {
5049 let (mut screen, _tx, _rx) = make_editor().await;
5050 screen.settings.write().unwrap().workspace_config =
5051 Some(crate::settings::workspace_config::WorkspaceConfig::new_empty());
5052 let mut term = ratatui::Terminal::new(ratatui::backend::TestBackend::new(24, 20)).unwrap();
5053 term.draw(|f| screen.render(f)).unwrap();
5054 let row: String = (0..24u16)
5055 .map(|x| term.backend().buffer()[(x, 0)].symbol().to_string())
5056 .collect();
5057 assert!(
5058 row.contains("Kimün"),
5059 "the left side keeps its room: {row:?}"
5060 );
5061 assert!(row.contains('≡'), "the palette door stays: {row:?}");
5062 assert!(
5063 !row.contains("Commands"),
5064 "but shrinks to the glyph: {row:?}"
5065 );
5066 assert!(
5067 screen
5068 .title_targets
5069 .iter()
5070 .any(|(_, t)| *t == TitleTarget::Palette)
5071 );
5072 }
5073
5074 use crate::test_support::drain as events;
5075
5076 #[tokio::test(flavor = "multi_thread")]
5079 async fn the_search_browser_sort_label_opens_a_stacked_sort_dialog() {
5080 let (mut screen, tx, mut rx) = make_editor().await;
5081 screen.open_overlay(OverlayOpen::SearchBrowser, &tx);
5082 let (col, row) = find_drawn(&mut screen, "Unsorted").expect("browser shows its sort");
5083 events(&mut rx);
5084 screen.handle_input(&crate::test_support::mouse_down_at(col, row), &tx);
5085 let sent = events(&mut rx);
5086 assert!(
5087 sent.iter()
5088 .any(|e| matches!(e, AppEvent::OpenSortDialog(SortTarget::Browser)))
5089 );
5090 screen
5091 .handle_app_message(AppEvent::OpenSortDialog(SortTarget::Browser), &tx)
5092 .await;
5093 assert_eq!(screen.overlays.active_kind(), Some(OverlayKind::Dialog));
5094 assert_eq!(
5095 screen.overlays.parked_kind(),
5096 Some(OverlayKind::NoteBrowser),
5097 "stacked over the browser, not replacing it"
5098 );
5099 }
5100
5101 #[tokio::test(flavor = "multi_thread")]
5104 async fn the_search_browser_opens_query_syntax_over_itself() {
5105 use ratatui::crossterm::event::KeyCode;
5106 let (mut screen, tx, mut rx) = make_editor().await;
5107 screen.open_overlay(OverlayOpen::SearchBrowser, &tx);
5108 let (col, row) = find_drawn(&mut screen, "[F1] Query syntax").expect("chip drawn");
5109 events(&mut rx);
5110 screen.handle_input(&crate::test_support::mouse_down_at(col, row), &tx);
5111 assert!(
5112 events(&mut rx)
5113 .iter()
5114 .any(|e| matches!(e, AppEvent::OpenQueryHelp))
5115 );
5116 screen
5117 .handle_app_message(AppEvent::OpenQueryHelp, &tx)
5118 .await;
5119 assert_eq!(screen.overlays.active_kind(), Some(OverlayKind::Dialog));
5120 assert_eq!(
5121 screen.overlays.parked_kind(),
5122 Some(OverlayKind::NoteBrowser)
5123 );
5124
5125 screen.overlays.close();
5127 assert_eq!(
5128 screen.overlays.active_kind(),
5129 Some(OverlayKind::NoteBrowser)
5130 );
5131 screen.handle_input(&key(KeyCode::F(1), false), &tx);
5132 assert_eq!(
5133 screen.overlays.parked_kind(),
5134 Some(OverlayKind::NoteBrowser)
5135 );
5136 }
5137
5138 #[tokio::test(flavor = "multi_thread")]
5140 async fn the_file_finder_has_no_syntax_chip() {
5141 let (mut screen, tx, _rx) = make_editor().await;
5142 screen.open_overlay(OverlayOpen::FileFinder, &tx);
5143 assert!(find_drawn(&mut screen, "Query syntax").is_none());
5144 assert!(find_drawn(&mut screen, "[Esc] Close").is_some());
5145 }
5146
5147 #[tokio::test(flavor = "multi_thread")]
5148 async fn the_find_syntax_chip_opens_the_reference() {
5149 let (mut screen, tx, mut rx) = make_editor().await;
5150 screen.panels.open_drawer_view(DrawerView::Find);
5151 let (col, row) = find_drawn(&mut screen, "[F1] Syntax").expect("chip drawn");
5152 events(&mut rx);
5153 screen.handle_input(&crate::test_support::mouse_down_at(col, row), &tx);
5154 assert!(
5155 events(&mut rx)
5156 .iter()
5157 .any(|e| matches!(e, AppEvent::OpenQueryHelp))
5158 );
5159 screen
5160 .handle_app_message(AppEvent::OpenQueryHelp, &tx)
5161 .await;
5162 assert_eq!(screen.overlays.active_kind(), Some(OverlayKind::Dialog));
5163 }
5164
5165 #[tokio::test(flavor = "multi_thread")]
5168 async fn title_bar_targets_open_palette_and_switcher() {
5169 let (mut screen, tx, _rx) = make_editor().await;
5170 screen.settings.write().unwrap().workspace_config =
5171 Some(crate::settings::workspace_config::WorkspaceConfig::new_empty());
5172 let (col, row) = find_drawn(&mut screen, "≡ Commands").expect("palette button drawn");
5173 screen.handle_input(&crate::test_support::mouse_down_at(col, row), &tx);
5174 assert_eq!(
5175 screen.overlays.active_kind(),
5176 Some(OverlayKind::CommandPalette)
5177 );
5178 screen.overlays.close_all();
5179
5180 find_drawn(&mut screen, "≡");
5181 let (col, row) = (0..120u16)
5182 .map(|c| (c, 0u16))
5183 .find(|&(c, r)| screen.title_target_at(c, r) == Some(TitleTarget::Workspace))
5184 .expect("workspace badge is a target");
5185 screen.handle_input(&crate::test_support::mouse_down_at(col, row), &tx);
5186 assert_eq!(screen.overlays.active_kind(), Some(OverlayKind::Dialog));
5187 }
5188
5189 #[tokio::test(flavor = "multi_thread")]
5190 async fn find_shows_a_sort_chip_only_while_querying() {
5191 let (mut screen, tx, mut rx) = make_editor().await;
5192 screen.panels.open_drawer_view(DrawerView::Find);
5193 assert!(
5194 find_drawn(&mut screen, "Name ↑").is_none(),
5195 "nothing to sort yet"
5196 );
5197 screen
5198 .handle_app_message(AppEvent::RunTagQuery("x".into()), &tx)
5199 .await;
5200 let (col, row) = find_drawn(&mut screen, "Name ↑").expect("a running query is sortable");
5201 let (_, query_row) = find_drawn(&mut screen, " Query").expect("query box drawn");
5202 assert_eq!(row, query_row, "the chip sits on the query box");
5203 while rx.try_recv().is_ok() {}
5204 screen.handle_input(&crate::test_support::mouse_down_at(col, row), &tx);
5205 assert!(
5206 crate::test_support::drain(&mut rx)
5207 .into_iter()
5208 .any(|e| matches!(e, AppEvent::OpenSortDialog(SortTarget::Query)))
5209 );
5210 }
5211
5212 #[tokio::test(flavor = "multi_thread")]
5213 async fn sort_save_default_persists_to_settings() {
5214 let (mut screen, tx, _rx) = make_editor().await;
5215 screen
5216 .handle_app_message(
5217 AppEvent::SortChanged {
5218 target: SortTarget::Sidebar,
5219 state: SortState {
5220 field: SortField::Title,
5221 order: SortOrder::Descending,
5222 group_dirs: Some(true),
5223 },
5224 persist: true,
5225 },
5226 &tx,
5227 )
5228 .await;
5229 let s = screen.settings.read().unwrap();
5230 assert_eq!(
5231 s.default_sort_field,
5232 crate::settings::SortFieldSetting::Title
5233 );
5234 assert_eq!(
5235 s.default_sort_order,
5236 crate::settings::SortOrderSetting::Descending
5237 );
5238 assert!(s.group_directories);
5239 }
5240
5241 #[tokio::test(flavor = "multi_thread")]
5242 async fn sort_save_default_journal_dir_writes_journal_settings() {
5243 let (mut screen, tx, _rx) = make_editor().await;
5244 let journal = screen.vault.journal_path().clone();
5246 screen.panels.sidebar_mut().navigate(journal, &tx);
5247
5248 screen
5249 .handle_app_message(
5250 AppEvent::SortChanged {
5251 target: SortTarget::Sidebar,
5252 state: SortState {
5253 field: SortField::Title,
5254 order: SortOrder::Ascending,
5255 group_dirs: Some(false),
5256 },
5257 persist: true,
5258 },
5259 &tx,
5260 )
5261 .await;
5262
5263 let s = screen.settings.read().unwrap();
5264 assert_eq!(
5265 s.journal_sort_field,
5266 crate::settings::SortFieldSetting::Title
5267 );
5268 assert_eq!(
5269 s.journal_sort_order,
5270 crate::settings::SortOrderSetting::Ascending
5271 );
5272 assert_eq!(
5274 s.default_sort_field,
5275 crate::settings::SortFieldSetting::Name
5276 );
5277 }
5278
5279 #[tokio::test(flavor = "multi_thread")]
5282 async fn sort_changed_without_persist_leaves_settings() {
5283 let (mut screen, tx, _rx) = make_editor().await;
5284 screen
5285 .handle_app_message(
5286 AppEvent::SortChanged {
5287 target: SortTarget::Sidebar,
5288 state: SortState {
5289 field: SortField::Title,
5290 order: SortOrder::Descending,
5291 group_dirs: Some(true),
5292 },
5293 persist: false,
5294 },
5295 &tx,
5296 )
5297 .await;
5298 let s = screen.settings.read().unwrap();
5299 assert_eq!(
5300 s.default_sort_field,
5301 crate::settings::SortFieldSetting::Name
5302 );
5303 assert_eq!(
5304 s.default_sort_order,
5305 crate::settings::SortOrderSetting::Ascending
5306 );
5307 assert!(!s.group_directories);
5308 }
5309
5310 fn key(code: ratatui::crossterm::event::KeyCode, ctrl: bool) -> InputEvent {
5311 use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
5312 let mods = if ctrl {
5313 KeyModifiers::CONTROL
5314 } else {
5315 KeyModifiers::NONE
5316 };
5317 InputEvent::Key(KeyEvent::new(code, mods))
5318 }
5319
5320 #[tokio::test(flavor = "multi_thread")]
5325 async fn ctrl_r_sorts_the_search_browser_and_close_restores_it() {
5326 use ratatui::crossterm::event::KeyCode;
5327 let (mut screen, tx, _rx) = make_editor().await;
5328 let focus = screen.panels.focused();
5329 screen.open_overlay(OverlayOpen::SearchBrowser, &tx);
5330 screen.handle_input(&key(KeyCode::Char('x'), false), &tx);
5331 assert_eq!(screen.overlays.active_query(), Some("x"));
5332
5333 screen.handle_input(&key(KeyCode::Char('r'), true), &tx);
5334 assert_eq!(screen.overlays.active_kind(), Some(OverlayKind::Dialog));
5335 assert_eq!(
5336 screen.overlays.parked_kind(),
5337 Some(OverlayKind::NoteBrowser),
5338 "the browser is parked under the dialog"
5339 );
5340
5341 screen
5342 .handle_app_message(
5343 AppEvent::SortChanged {
5344 target: SortTarget::Browser,
5345 state: SortState {
5346 field: SortField::Title,
5347 order: SortOrder::Ascending,
5348 group_dirs: None,
5349 },
5350 persist: false,
5351 },
5352 &tx,
5353 )
5354 .await;
5355
5356 screen.handle_app_message(AppEvent::CloseOverlay, &tx).await;
5357 assert_eq!(
5358 screen.overlays.active_kind(),
5359 Some(OverlayKind::NoteBrowser)
5360 );
5361 assert_eq!(screen.overlays.parked_kind(), None);
5362 assert_eq!(screen.overlays.active_query(), Some("x or:title"));
5363 assert_eq!(screen.panels.focused(), focus, "panel focus unchanged");
5364
5365 screen.handle_app_message(AppEvent::CloseOverlay, &tx).await;
5367 assert!(!screen.overlays.is_open());
5368 assert_eq!(screen.panels.focused(), focus);
5369 }
5370
5371 #[tokio::test(flavor = "multi_thread")]
5374 async fn ctrl_r_over_the_file_finder_opens_the_sort_dialog() {
5375 use ratatui::crossterm::event::KeyCode;
5376 let (mut screen, tx, _rx) = make_editor().await;
5377 let focus = screen.panels.focused();
5378 screen.open_overlay(OverlayOpen::FileFinder, &tx);
5379 screen.handle_input(&key(KeyCode::Char('r'), true), &tx);
5380 assert_eq!(screen.overlays.active_kind(), Some(OverlayKind::Dialog));
5381 assert_eq!(
5382 screen.overlays.parked_kind(),
5383 Some(OverlayKind::NoteBrowser),
5384 "the finder is parked under the dialog"
5385 );
5386 screen.handle_app_message(AppEvent::CloseOverlay, &tx).await;
5387 assert_eq!(
5388 screen.overlays.active_kind(),
5389 Some(OverlayKind::NoteBrowser)
5390 );
5391 screen.handle_app_message(AppEvent::CloseOverlay, &tx).await;
5392 assert!(!screen.overlays.is_open());
5393 assert_eq!(screen.panels.focused(), focus);
5394 }
5395
5396 #[tokio::test(flavor = "multi_thread")]
5399 async fn sort_dialog_over_a_fresh_file_finder_says_unsorted() {
5400 use ratatui::crossterm::event::KeyCode;
5401 let (mut screen, tx, _rx) = make_editor().await;
5402 assert!(!screen.panels.sidebar().is_unsorted());
5403 assert!(!screen.panels.query().is_unsorted());
5404 screen.open_overlay(OverlayOpen::FileFinder, &tx);
5405 screen.handle_input(&key(KeyCode::Char('r'), true), &tx);
5406 assert_eq!(screen.overlays.active_kind(), Some(OverlayKind::Dialog));
5407 let mut term = ratatui::Terminal::new(ratatui::backend::TestBackend::new(120, 40)).unwrap();
5408 term.draw(|f| screen.render(f)).unwrap();
5409 let text: String = term
5410 .backend()
5411 .buffer()
5412 .content()
5413 .iter()
5414 .map(|c| c.symbol())
5415 .collect();
5416 assert!(text.contains("Unsorted"), "dialog shows Unsorted");
5417 }
5418
5419 #[tokio::test(flavor = "multi_thread")]
5422 async fn sidebar_property_sort_is_never_persisted() {
5423 let (mut screen, tx, mut rx) = make_editor().await;
5424 screen
5425 .handle_app_message(
5426 AppEvent::SortChanged {
5427 target: SortTarget::Sidebar,
5428 state: SortState {
5429 field: SortField::Property("due".into()),
5430 order: SortOrder::Descending,
5431 group_dirs: Some(true),
5432 },
5433 persist: true,
5434 },
5435 &tx,
5436 )
5437 .await;
5438 {
5439 let s = screen.settings.read().unwrap();
5440 assert_eq!(
5441 s.default_sort_field,
5442 crate::settings::SortFieldSetting::Name
5443 );
5444 assert_eq!(
5445 s.default_sort_order,
5446 crate::settings::SortOrderSetting::Ascending
5447 );
5448 assert!(!s.group_directories);
5449 }
5450 assert_eq!(
5451 screen.panels.sidebar().current_sort(),
5452 (SortField::Property("due".into()), SortOrder::Descending),
5453 "the sort still applies live"
5454 );
5455 let mut flashed = false;
5456 while let Ok(evt) = rx.try_recv() {
5457 if let AppEvent::FlashMessage(msg) = evt {
5458 flashed |= msg.contains("can't be saved");
5459 }
5460 }
5461 assert!(flashed, "the user is told why nothing was saved");
5462 }
5463
5464 async fn browser_under_sort_dialog(screen: &mut EditorScreen, tx: &AppTx) {
5466 use ratatui::crossterm::event::KeyCode;
5467 screen.open_overlay(OverlayOpen::SearchBrowser, tx);
5468 screen.handle_input(&key(KeyCode::Char('r'), true), tx);
5469 assert_eq!(
5470 screen.overlays.parked_kind(),
5471 Some(OverlayKind::NoteBrowser)
5472 );
5473 }
5474
5475 #[tokio::test(flavor = "multi_thread")]
5477 async fn entry_op_closes_the_whole_overlay_stack() {
5478 let (mut screen, tx, _rx) = make_editor().await;
5479 let focus = screen.panels.focused();
5480 browser_under_sort_dialog(&mut screen, &tx).await;
5481 screen
5482 .on_entry_op(VaultPath::note_path_from("elsewhere.md"), &tx)
5483 .await;
5484 assert!(!screen.overlays.is_open());
5485 assert_eq!(screen.overlays.parked_kind(), None);
5486 assert_eq!(screen.panels.focused(), focus);
5487 }
5488
5489 #[tokio::test(flavor = "multi_thread")]
5491 async fn note_rename_closes_the_whole_overlay_stack() {
5492 let (mut screen, tx, _rx) = make_editor().await;
5493 browser_under_sort_dialog(&mut screen, &tx).await;
5494 screen
5495 .on_note_renamed(
5496 VaultPath::note_path_from("a.md"),
5497 VaultPath::note_path_from("b.md"),
5498 &tx,
5499 )
5500 .await;
5501 assert!(!screen.overlays.is_open());
5502 assert_eq!(screen.overlays.parked_kind(), None);
5503 }
5504}