Skip to main content

kimun_notes/app_screen/
editor.rs

1use std::sync::Arc;
2use std::time::Duration;
3
4use async_trait::async_trait;
5use kimun_core::NoteVault;
6use kimun_core::error::{FSError, VaultError};
7use kimun_core::nfs::VaultPath;
8use ratatui::layout::{Constraint, Direction, Layout};
9use ratatui::style::Style;
10use ratatui::widgets::Paragraph;
11
12use crate::app_screen::ask::AskCoordinator;
13use crate::app_screen::editor_input::{
14    Classification, CycleDir, EditorIntent, EditorOp, InputCtx, OverlayOpen, PanelFallback,
15    classify,
16};
17use crate::app_screen::overlay_host::OverlayHost;
18use crate::app_screen::panel_set::PanelSet;
19use crate::app_screen::{AppScreen, ScreenKind};
20use crate::components::ask_thread::ThreadPanel;
21use crate::components::attachment_view::AttachmentView;
22use crate::components::autosave_timer::AutosaveTimer;
23use crate::components::dialogs::ActiveDialog;
24use crate::components::drawer::{DrawerHost, DrawerView};
25use crate::components::drawer_views::{LinksPanel, OutlinePanel, TagsPanel};
26use crate::components::event_state::EventState;
27use crate::components::events::{
28    AppEvent, AppTx, FileOp, InputEvent, OverlayData, SaveSource, SavedSearchFlow, ScreenEvent,
29    SortTarget, UpdateFlow,
30};
31use crate::components::file_list::FileListEntry;
32use crate::components::footer_bar::FooterBar;
33use crate::components::note_browser::file_finder_provider::FileFinderProvider;
34use crate::components::note_browser::search_provider::resolving_search_source;
35use crate::components::note_browser::{BrowserScope, NoteBrowserModal};
36use crate::components::overlay::{Overlay, OverlayKind};
37use crate::components::panel::PanelKind;
38use crate::components::query_panel::QueryPanel;
39use crate::components::saved_searches_modal::SavedSearchesModal;
40use crate::components::sidebar::SidebarComponent;
41use crate::components::text_editor::TextEditorComponent;
42use crate::keys::KeyBindings;
43use crate::keys::action_shortcuts::{ActionShortcuts, TextAction};
44use crate::keys::leader::{LeaderAction, LeaderEngine, LeaderOutcome};
45use crate::settings::SharedSettings;
46use crate::settings::icons::Icons;
47use crate::settings::themes::Theme;
48use crate::util::single_slot_task::SingleSlotTask;
49
50/// Hard cap on every blocking save path so a stuck disk (NFS hang,
51/// fsync stall) cannot freeze quit, navigation, or the next autosave
52/// tick. The same value is used both to wait for an in-flight
53/// background autosave and to bound our own synchronous save call —
54/// the worst-case quit time is therefore one cap plus the
55/// observability of `abort()` on a syscall in progress.
56const SAVE_TIMEOUT: Duration = Duration::from_secs(5);
57
58pub struct EditorScreen {
59    vault: Arc<NoteVault>,
60    settings: SharedSettings,
61    icons: Icons,
62    theme: Theme,
63    /// The persistent panels (sidebar, editor, Query panel) plus their order,
64    /// visibility, and focus. The host reaches a specific panel through the
65    /// typed accessors (`panels.editor_mut()`, …) for panel-specific calls.
66    panels: PanelSet,
67    path: VaultPath,
68    footer: FooterBar,
69    /// Async document/status state: backlink count, git summary, link
70    /// affordance cache, pending emphasis needles (see `doc_meta.rs`).
71    doc_meta: crate::app_screen::doc_meta::DocMeta,
72    /// App-global update notice, seeded by `AppEvent::Update(UpdateFlow::Available)`. Drives
73    /// the footer indicator; `None` when up to date or the check found nothing.
74    update: Option<crate::update::UpdateStatus>,
75    /// Latest RAG connection/sync status from the background sync task, shown in
76    /// the footer. `Disabled` (no server) renders nothing.
77    rag_status: crate::rag::RagStatus,
78    /// The Ask workspace's coordination layer: Thread↔Sources sync, capability
79    /// refresh, AskData routing, and show/stash transitions (see `ask.rs`).
80    ask: AskCoordinator,
81    /// The leader-key sequence state machine (Ctrl-G gateway, spec §8a).
82    leader: LeaderEngine,
83    /// The mouse presses currently forming one gesture, so a double-click in
84    /// the editor can follow a link (see `click_run`). Fed before each
85    /// classification; its answer reaches the classifier as `InputCtx`.
86    clicks: crate::app_screen::click_run::ClickRun,
87    /// App event sender, captured on enter — render-side async kicks (the
88    /// link-affordance backlink fetch) need it where no `tx` is threaded.
89    app_tx: Option<AppTx>,
90    autosave: AutosaveTimer,
91    /// The active overlay, if any. An open overlay intercepts input ahead of
92    /// the panels; closing it restores focus to the panel that opened it.
93    overlays: OverlayHost<PanelKind>,
94    /// Handle to the most recently spawned background autosave task.
95    /// `is_in_flight()` is the source of truth for "is a save still in
96    /// flight"; both successful completion AND panic flip it to false
97    /// so the next periodic tick can spawn fresh. The synchronous save
98    /// paths (`open_path` / `on_entry_op` / `on_exit`) await this slot
99    /// before issuing their own `vault.save_note`, so two concurrent
100    /// writes for the same path can never collide. Drop aborts the
101    /// in-flight task so the spawned future cannot outlive the screen.
102    autosave_task: SingleSlotTask<()>,
103}
104
105impl EditorScreen {
106    pub fn new(vault: Arc<NoteVault>, path: VaultPath, settings: SharedSettings) -> Self {
107        let s = settings.read().unwrap();
108        let kb = s.key_bindings.clone();
109        let theme = s.get_theme();
110        let footer = FooterBar::new();
111        let icons = s.icons();
112        let sidebar = SidebarComponent::from_settings(vault.clone(), &s);
113        let query_panel = QueryPanel::new(vault.clone(), kb.clone(), icons.clone());
114        let semantic = crate::components::semantic_search::SemanticPanel::new(
115            vault.clone(),
116            settings.clone(),
117            s.icons(),
118            s.yank_combos(),
119        );
120        let tags = TagsPanel::new(vault.clone(), s.icons(), s.yank_combos());
121        let links = LinksPanel::new(vault.clone(), s.icons(), s.yank_combos());
122        let outline = OutlinePanel::new(vault.clone(), s.icons(), s.yank_combos());
123        let drawer = DrawerHost::new(
124            vault.clone(),
125            &kb,
126            sidebar,
127            query_panel,
128            semantic,
129            tags,
130            links,
131            outline,
132        );
133        let rail_kb = kb.clone();
134        let mut editor = TextEditorComponent::new(kb, &s);
135        editor.set_vault(vault.clone());
136        let leader_engine = LeaderEngine::with_tree(s.leader_tree());
137        drop(s);
138        let rail_icons = icons.clone();
139        let ask = AskCoordinator::new(settings.clone(), vault.clone());
140        Self {
141            settings,
142            icons,
143            theme,
144            // SEM and ASK both start hidden: no RAG status has arrived yet, so
145            // the server's search/LLM capability is unknown. The rail rebuilds
146            // when the first health probe lands (both are status-driven).
147            panels: PanelSet::from_panels(
148                drawer,
149                editor,
150                rail_icons,
151                rail_kb,
152                crate::components::activity_rail::RailCaps {
153                    semantic: false,
154                    ask: false,
155                },
156            ),
157            doc_meta: crate::app_screen::doc_meta::DocMeta::new(vault.clone()),
158            update: None,
159            rag_status: crate::rag::RagStatus::Disabled,
160            ask,
161            vault,
162            path,
163            footer,
164            leader: leader_engine,
165            clicks: Default::default(),
166            app_tx: None,
167            autosave: AutosaveTimer::new(),
168            overlays: OverlayHost::new(),
169            autosave_task: SingleSlotTask::empty(),
170        }
171    }
172}
173
174/// Encodes raw RGBA pixels as a PNG byte stream.
175fn encode_rgba_to_png(width: u32, height: u32, rgba: &[u8]) -> std::io::Result<Vec<u8>> {
176    let mut buf = Vec::new();
177    {
178        let mut encoder = png::Encoder::new(&mut buf, width, height);
179        encoder.set_color(png::ColorType::Rgba);
180        encoder.set_depth(png::BitDepth::Eight);
181        let mut writer = encoder
182            .write_header()
183            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
184        writer
185            .write_image_data(rgba)
186            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
187    }
188    Ok(buf)
189}
190
191impl EditorScreen {
192    /// Pulls an image off the system clipboard, encodes it to PNG, saves it as
193    /// an attachment under the vault's `/assets` directory, and inserts a
194    /// markdown image link (relative to the current note) at the cursor.
195    ///
196    /// Returns `true` if a clipboard image was found and the paste was
197    /// dispatched — even if the encode/save is still in flight. Returns
198    /// `false` if the clipboard contained no image, so the caller can fall
199    /// through to a regular text paste.
200    fn try_paste_image(&mut self, tx: &AppTx) -> bool {
201        let Some(editor) = self.panels.editor_mut() else {
202            return false;
203        };
204        let img = match editor.take_clipboard_image() {
205            Some(i) if !i.rgba.is_empty() && i.width > 0 && i.height > 0 => i,
206            _ => return false,
207        };
208        // arboard contract: rgba length == width * height * 4. A mismatch means
209        // a misbehaving clipboard provider — refuse rather than encode garbage.
210        let expected = img
211            .width
212            .checked_mul(img.height)
213            .and_then(|n| n.checked_mul(4));
214        if expected != Some(img.rgba.len()) {
215            self.footer
216                .flash("Clipboard image size mismatch".to_string(), tx);
217            return true;
218        }
219        // The selection is NOT dropped here. Both steps below can fail — the
220        // PNG encode and the attachment save — and a cut done now would destroy
221        // the user's text with nothing to replace it. `insert_at_cursor`, which
222        // runs only on success, does the replacement and reconciles the vim
223        // engine out of Visual in the same breath.
224        let asset_path = self.vault.generate_attachment_path("image", "png");
225        let link_path = asset_path.relative_link_from_note(&self.path);
226        let markdown = format!("![]({link_path})");
227        let vault = self.vault.clone();
228        let tx2 = tx.clone();
229        let width = img.width as u32;
230        let height = img.height as u32;
231        let rgba = img.rgba;
232        tokio::spawn(async move {
233            // PNG encoding is CPU-bound — keep it off the runtime worker threads.
234            let png_bytes =
235                match tokio::task::spawn_blocking(move || encode_rgba_to_png(width, height, &rgba))
236                    .await
237                {
238                    Ok(Ok(b)) => b,
239                    Ok(Err(e)) => {
240                        tx2.send(AppEvent::OverlayData(OverlayData::Error(format!(
241                            "Image encode failed: {e}"
242                        ))))
243                        .ok();
244                        return;
245                    }
246                    Err(e) => {
247                        tx2.send(AppEvent::OverlayData(OverlayData::Error(format!(
248                            "Image encode task failed: {e}"
249                        ))))
250                        .ok();
251                        return;
252                    }
253                };
254            match vault.save_attachment(&asset_path, &png_bytes).await {
255                Ok(()) => {
256                    tx2.send(AppEvent::InsertAtCursor(markdown)).ok();
257                    // Only on success — the failure branches below already
258                    // report, and this path is async, so the chord echo would
259                    // otherwise be the last thing the user saw.
260                    tx2.send(AppEvent::FlashMessage("image pasted".into())).ok();
261                }
262                Err(e) => {
263                    tx2.send(AppEvent::OverlayData(OverlayData::Error(format!(
264                        "Image save failed: {e}"
265                    ))))
266                    .ok();
267                }
268            }
269        });
270        true
271    }
272
273    /// Persist a saved search via core. Used by the SaveSearchConfirmed handler
274    /// and unit tests.
275    #[cfg(test)]
276    async fn persist_saved_search(&self, name: &str, query: &str) -> Result<(), VaultError> {
277        self.vault.save_search(name, query).await
278    }
279
280    async fn follow_link(&mut self, target: String, tx: &AppTx) {
281        // External URL — hand off to the OS browser/handler.
282        if kimun_core::note::scan::is_remote_url(&target) {
283            match open::that_detached(&target) {
284                Ok(()) => self.footer.flash(format!("Opening {target}"), tx),
285                Err(e) => self.footer.flash(format!("Cannot open URL: {e}"), tx),
286            }
287            return;
288        }
289
290        // Image attachment — resolve the (potentially relative) path against
291        // the current note's directory, convert to an OS path, hand off to the
292        // OS default handler. Images are not notes, so skip the note lookup.
293        if kimun_core::note::scan::target_looks_like_image(&target) {
294            let parent = self.path.get_parent_path().0;
295            let resolved = parent.append(&VaultPath::new(target.trim()));
296            let os_path = self.vault.path_to_pathbuf(&resolved);
297            match open::that_detached(&os_path) {
298                Ok(()) => self.footer.flash(format!("Opening {target}"), tx),
299                Err(e) => self.footer.flash(format!("Cannot open image: {e}"), tx),
300            }
301            return;
302        }
303
304        // Note reference — look it up in the vault.
305        // Strip any `#fragment` suffix before resolving (e.g. `notes/design.md#goals`
306        // should resolve to `notes/design.md`, not `notes/design.md#goals.md`).
307        let target_clean = target.split('#').next().unwrap_or(&target).trim_end();
308        // Resolve the (possibly relative, e.g. `../work/anton.md`) target
309        // against this note's directory so the existence lookup uses the same
310        // absolute path the note is stored under. Bare names stay name-lookups.
311        let path = kimun_core::nfs::VaultPath::note_path_from(target_clean)
312            .resolve_link_in_note(&self.path);
313        match self.vault.open_or_search(&path).await {
314            Ok(results) if results.is_empty() => {
315                self.present_overlay(Box::new(ActiveDialog::create_note(
316                    path,
317                    self.vault.clone(),
318                    None,
319                )));
320            }
321            Ok(mut results) if results.len() == 1 => {
322                let (entry, _) = results.remove(0);
323                self.open_path(entry.path, None, tx).await;
324            }
325            Ok(results) => {
326                use crate::components::note_browser::link_results_provider::LinkResultsProvider;
327                let provider = LinkResultsProvider::from_results(results);
328                let s = self.settings.read().unwrap();
329                let modal = NoteBrowserModal::new(
330                    format!("Follow: {target}"),
331                    BrowserScope::Files,
332                    provider,
333                    self.vault.clone(),
334                    s.key_bindings.clone(),
335                    s.icons(),
336                    tx.clone(),
337                );
338                drop(s);
339                self.present_overlay(Box::new(modal));
340            }
341            Err(e) => {
342                self.footer.flash(format!("Link error: {e}"), tx);
343            }
344        }
345    }
346
347    pub async fn open_path(&mut self, path: VaultPath, emphasis: Option<Vec<String>>, tx: &AppTx) {
348        if !path.is_note() {
349            // A non-note path is either an attachment (show it in place of the
350            // editor) or a directory (browse it). Classify so a stray
351            // attachment open here still lands in the attachment view rather
352            // than the directory browser.
353            if let Ok(kimun_core::EntryKind::Attachment) = self.vault.entry_kind(&path).await {
354                self.open_attachment(path, tx).await;
355                return;
356            }
357            tx.send(AppEvent::OpenScreen(ScreenEvent::OpenBrowse(
358                self.vault.clone(),
359                path,
360            )))
361            .ok();
362            return;
363        }
364
365        // Save current note before switching
366        self.try_save().await;
367
368        self.path = path.clone();
369        // Returning to a note swaps the editor area back from any attachment or
370        // the Ask workspace. `clear_attachment` and `hide_ask_if_shown` each
371        // only touch their own content, so both are needed to guarantee the
372        // note editor is what shows (carry-forward: open note over Ask/attach).
373        self.hide_ask_if_shown();
374        self.panels.clear_attachment();
375        // Mark this note's row in the sidebar (clears the previous one).
376        self.panels
377            .sidebar_mut()
378            .set_open_note(Some(self.path.clone()));
379        match self.vault.get_note_text(&self.path).await {
380            Ok(content) => {
381                // Recorded only once the read succeeds: a note that isn't
382                // there was never opened, and history is what the next
383                // launch restores.
384                self.settings.read().unwrap().add_path_history(&path);
385                let settings_snapshot = self.settings.read().unwrap().clone();
386                tokio::spawn(async move {
387                    settings_snapshot.save_to_disk().ok();
388                });
389                self.doc_meta.note_opened(&self.path, tx);
390                if let Some(ed) = self.panels.editor_mut() {
391                    ed.set_text(content);
392                    // Arrive-from-query emphasis: apply after the load so the
393                    // buffer's new revision owns the needles.
394                    if let Some(needles) = emphasis {
395                        ed.set_search_needles(needles);
396                    }
397                    ed.set_redraw_tx(tx);
398                }
399                tx.send(AppEvent::Redraw).ok();
400                // FIND / LINKS / OUTLINE reflect the open note; keep them in
401                // step. Shared with `on_note_renamed` via the helper.
402                self.reflect_open_note_in_drawers(tx);
403            }
404            Err(e) => {
405                if matches!(e, VaultError::FSError(FSError::VaultPathNotFound { .. })) {
406                    self.present_overlay(Box::new(ActiveDialog::create_note(
407                        self.path.clone(),
408                        self.vault.clone(),
409                        None,
410                    )));
411                } else {
412                    tracing::error!("Failed to read note {}: {e}", self.path);
413                    let parent = self.path.get_parent_path().0;
414                    tx.send(AppEvent::OpenScreen(ScreenEvent::OpenBrowse(
415                        self.vault.clone(),
416                        parent,
417                    )))
418                    .ok();
419                }
420                return;
421            }
422        }
423
424        // Load the sidebar on first open only; refreshes happen via explicit
425        // create/rename/delete/move events, not on every note open.
426        let note_parent = path.get_parent_path().0;
427        if self.panels.sidebar().is_empty() {
428            self.navigate_sidebar(note_parent, tx);
429        }
430
431        // Abort any existing timer and spawn a fresh one for the new note.
432        let interval = self.settings.read().unwrap().autosave_interval_secs;
433        self.autosave.restart(interval, tx.clone());
434    }
435
436    /// Take the editor area off the Ask workspace before it's swapped to
437    /// another view (a note or an attachment). `clear_attachment` and
438    /// `show_attachment` both leave Ask content alone, so this is what
439    /// guarantees the new view actually shows (see
440    /// `AskCoordinator::hide_if_shown` for the drawer-view rules).
441    fn hide_ask_if_shown(&mut self) {
442        self.ask.hide_if_shown(&mut self.panels);
443    }
444
445    /// Show the attachment at `path` in the editor area's read-only attachment
446    /// view. Saves and unmounts the open note first; while an
447    /// attachment is shown there is no open note, so the autosave task is
448    /// aborted and the sidebar's open-note marker cleared. The next periodic
449    /// autosave tick no-ops because the note editor is absent.
450    async fn open_attachment(&mut self, path: VaultPath, tx: &AppTx) {
451        // Persist the current note before swapping the editor area away from it.
452        self.try_save().await;
453        self.autosave_task.abort();
454
455        match self.vault.get_attachment_details(&path).await {
456            Ok(details) => {
457                let (icons, kb) = {
458                    let s = self.settings.read().unwrap();
459                    (s.icons(), s.key_bindings.clone())
460                };
461                let view = AttachmentView::new(details, icons, kb);
462                self.path = path;
463                // Take the area off Ask so the attachment actually replaces it
464                // (show_attachment leaves Ask content alone).
465                self.hide_ask_if_shown();
466                self.panels.show_attachment(view);
467                self.panels.sidebar_mut().set_open_note(None);
468                tx.send(AppEvent::Redraw).ok();
469            }
470            Err(e) => {
471                self.footer
472                    .flash(format!("Cannot open attachment: {e}"), tx);
473            }
474        }
475    }
476
477    fn navigate_sidebar(&mut self, dir: VaultPath, tx: &AppTx) {
478        // The sidebar hosts a streamed `SearchList`; (re)building its engine for
479        // `dir` runs `browse_vault_stream` inside the source and emits rows as they
480        // arrive (with a redraw on each).
481        self.panels.sidebar_mut().navigate(dir, tx);
482    }
483
484    /// Rebuild the sidebar listing only when it is currently showing `dir` —
485    /// used after entry create/rename/move ops so the change appears without
486    /// yanking the user away from an unrelated directory they browsed to.
487    /// Deliberately the inverse of `reveal_note_dir_in_sidebar`, which
488    /// navigates when the sidebar is NOT on the target dir.
489    fn refresh_sidebar_if_showing(&mut self, dir: &VaultPath, tx: &AppTx) {
490        self.panels.sidebar_mut().refresh_if_showing(dir, tx);
491    }
492
493    /// A note at `path` was just saved with raw title `raw_title`; update its
494    /// sidebar row in place. Keyed by the saved path, not the open note, so a
495    /// just-saved-then-deselected note's row updates too.
496    fn note_saved(&mut self, path: &VaultPath, raw_title: String) {
497        let title = FileListEntry::display_title(raw_title);
498        self.panels.sidebar_mut().update_note_row(path, &title);
499    }
500
501    async fn try_save(&mut self) {
502        // Wait out any background autosave so two concurrent `vault.save_note`
503        // calls cannot race on the same path. Capped at 5s so a wedged
504        // filesystem (NFS hang, fsync stall, SQLite lock contention) does
505        // not freeze app-quit indefinitely.
506        //
507        // If the timeout fires we MUST abort the prior task and bail
508        // without issuing our own save: dropping a JoinHandle detaches
509        // the tokio task rather than cancelling it, so the spawned
510        // vault.save_note keeps running. Calling our own vault.save_note
511        // on the same path on top of that is the exact two-writer race
512        // the in-flight serialisation is meant to prevent. abort() is
513        // best-effort (will not unwind an in-progress syscall) but it
514        // stops any further await points in the spawned task. The editor
515        // stays dirty so the next session retries; the spawned task
516        // either finishes against the disk on its own or is killed when
517        // the process exits.
518        if self.autosave_task.is_in_flight() {
519            match self.autosave_task.await_with_timeout(SAVE_TIMEOUT).await {
520                Some(_) => {} // completed (success or panic) — slot already cleared
521                None => {
522                    // Timeout: abort the spawned task and bail.
523                    self.autosave_task.abort();
524                    return;
525                }
526            }
527        }
528        // No note editor mounted (an attachment is shown) → nothing to save.
529        let Some(text) = self
530            .panels
531            .editor()
532            .filter(|e| e.is_dirty())
533            .map(|e| e.get_text())
534        else {
535            return;
536        };
537        // Same cap on our own save so quit cannot hang on a stuck
538        // disk. A timeout returns Err(_); we skip mark_saved so the
539        // editor stays dirty for any subsequent retry.
540        let save = self.vault.save_note(&self.path, &text);
541        if let Ok(Ok((_, content))) = tokio::time::timeout(SAVE_TIMEOUT, save).await {
542            if let Some(ed) = self.panels.editor_mut() {
543                ed.mark_saved(text);
544            }
545            let path = self.path.clone();
546            self.note_saved(&path, content.title);
547        }
548    }
549
550    /// Fire-and-forget autosave used by the periodic timer. The save runs in
551    /// a spawned tokio task so the main event loop is never blocked by the
552    /// filesystem + SQLite write. Completion is reported back as
553    /// `AppEvent::AutosaveCompleted`, which marks the editor clean iff the
554    /// editor is still at the revision that was written. `is_in_flight()`
555    /// on the `SingleSlotTask` slot is the "is a save in flight" signal;
556    /// it flips to false on both successful completion AND panic, so a
557    /// single panicked task can never permanently disable autosave.
558    fn spawn_autosave(&mut self, tx: &AppTx) {
559        // A previous task that hasn't reported completion yet still holds the
560        // lock on the file system + SQLite path; let it finish first.
561        if self.autosave_task.is_in_flight() {
562            return;
563        }
564        let Some(ed) = self.panels.editor() else {
565            return;
566        };
567        if !ed.is_dirty() {
568            return;
569        }
570        let text = ed.get_text();
571        let revision = ed.content_revision();
572        let vault = self.vault.clone();
573        let path = self.path.clone();
574        let tx = tx.clone();
575        self.autosave_task.spawn(async move {
576            let (saved_revision, title) = match vault.save_note(&path, &text).await {
577                Ok((_, content)) => (Some(revision), Some(content.title)),
578                Err(_) => (None, None),
579            };
580            let _ = tx.send(AppEvent::AutosaveCompleted {
581                path,
582                saved_revision,
583                title,
584            });
585        });
586    }
587
588    /// The panel to restore focus to when the active overlay closes — the
589    /// panel that was focused when the overlay opened.
590    fn opener_focus(&self) -> PanelKind {
591        self.panels.focused()
592    }
593
594    /// Present `overlay`, recording the currently focused panel as its opener so
595    /// `dismiss_overlay` can return there on close. The single way the editor
596    /// opens an overlay — the focus contract lives here, not at each call site.
597    fn present_overlay(&mut self, overlay: Box<dyn Overlay>) {
598        // An overlay taking input must never leave a leader sequence armed —
599        // its keys would be eaten by the leader intercept.
600        self.leader.cancel();
601        let opener = self.opener_focus();
602        self.overlays.open(overlay, opener);
603    }
604
605    /// Close the active overlay and restore focus to the panel that opened it.
606    /// The close-side mirror of `present_overlay`. `OverlayHost::close` returns
607    /// `None` when nothing is open, so this is a no-op then — which is also why
608    /// a selection that closed the overlay itself (and chose its own focus) is
609    /// not re-restored by a trailing `CloseOverlay`.
610    fn dismiss_overlay(&mut self) {
611        if let Some(opener) = self.overlays.close() {
612            self.panels.focus(opener);
613        }
614    }
615
616    async fn on_entry_op(&mut self, from: VaultPath, tx: &AppTx) {
617        self.dismiss_overlay();
618        // `is_like` ignores the vault-relative/absolute distinction: `from` (from
619        // a sidebar/query row) is index-absolute while `self.path` may be relative.
620        // A plain `==` would miss, leaving the stale autosave to
621        // recreate a deleted/moved note.
622        if from.is_like(&self.path) {
623            self.autosave.stop();
624            self.try_save().await;
625            let parent = self.path.get_parent_path().0;
626            tx.send(AppEvent::OpenScreen(ScreenEvent::OpenBrowse(
627                self.vault.clone(),
628                parent,
629            )))
630            .ok();
631        } else {
632            self.refresh_sidebar_if_showing(&from.get_parent_path().0, tx);
633        }
634    }
635
636    /// A note was renamed. Update its sidebar row in place; if it is the note
637    /// currently open, retarget the editor to the new path and reload the body
638    /// from disk so any self-link rewrites from the rename land in the buffer
639    /// (the in-memory text still holds the pre-rename self-links). We
640    /// deliberately do NOT `try_save` — the old path no longer exists on disk.
641    async fn on_note_renamed(&mut self, from: VaultPath, to: VaultPath, tx: &AppTx) {
642        self.dismiss_overlay();
643        self.panels.sidebar_mut().rename_note_row(&from, &to);
644        // `is_like` so an absolute `from` (index row) matches a possibly-relative
645        // `self.path` — otherwise the retarget + autosave-abort below
646        // are skipped and the stale save resurrects the old path.
647        if from.is_like(&self.path) {
648            // The open note was renamed. Kill any in-flight autosave still
649            // targeting the OLD path before retargeting (spawn_autosave bakes
650            // the path in; vault.save_note writes unconditionally, so a stale
651            // save would recreate the renamed-away file). abort() is
652            // best-effort (can't unwind a syscall already in progress).
653            self.autosave_task.abort();
654            match self.vault.get_note_text(&to).await {
655                Ok(text) => {
656                    self.path = to.clone();
657                    if let Some(ed) = self.panels.editor_mut() {
658                        ed.set_text(text.clone());
659                        ed.mark_saved(text);
660                    }
661                    self.panels
662                        .sidebar_mut()
663                        .set_open_note(Some(self.path.clone()));
664                    self.doc_meta.note_opened(&self.path, tx);
665                    self.reflect_open_note_in_drawers(tx);
666                    // Fresh autosave timer for the new path (mirrors open_path).
667                    let interval = self.settings.read().unwrap().autosave_interval_secs;
668                    self.autosave.restart(interval, tx.clone());
669                }
670                Err(_) => {
671                    // Couldn't load the renamed note — do NOT keep a dirty
672                    // buffer pointed at it (autosave would clobber the
673                    // on-disk rewrite). Fall back to Browse on the new
674                    // parent dir.
675                    self.autosave.stop();
676                    let parent = to.get_parent_path().0;
677                    tx.send(AppEvent::OpenScreen(ScreenEvent::OpenBrowse(
678                        self.vault.clone(),
679                        parent,
680                    )))
681                    .ok();
682                }
683            }
684        }
685    }
686}
687
688impl EditorScreen {
689    /// Snapshot of the screen state the input classifier reads. Built per
690    /// classification — cheap field reads only.
691    ///
692    /// `double_click` comes from [`Self::track_click`] rather than a field read
693    /// because feeding the click run mutates it; a snapshot is taken from
694    /// `&self` and cannot.
695    fn input_ctx(&self, double_click: bool) -> InputCtx {
696        InputCtx {
697            overlay: self.overlays.active_kind(),
698            leader_pending: self.leader.is_pending(),
699            focused: self.panels.focused(),
700            drawer_view: self.panels.active_drawer_view(),
701            space_leads: self.panels.editor().is_some_and(|e| e.space_leads()),
702            claim: self.panels.editor().map(|e| e.claim()).unwrap_or_default(),
703            double_click,
704        }
705    }
706
707    /// Feed an event to the click run and report whether it completes a
708    /// double-click on the note buffer.
709    ///
710    /// The screen answers only what a `ClickRun` cannot answer for itself:
711    /// whether the event is a mouse event at all, whether an overlay is in the
712    /// way, and whether the press landed on the note buffer. Which *mouse*
713    /// events end a run is `ClickRun`'s policy and lives there, where it can be
714    /// tested against a real event sequence.
715    ///
716    /// An open overlay ends the run rather than merely failing to extend it.
717    /// The classifier already gives an overlay precedence, so no follow can
718    /// fire *while* one is open — but a modal is drawn over the editor column,
719    /// and a `SearchList` activates a row on a click-click of its own. Without
720    /// ending it here, those presses stay in the run and pair with the first
721    /// press that lands after the overlay closes.
722    ///
723    /// `now` is passed in for the same reason [`ClickRun`] takes it rather than
724    /// reading a clock: a test describes a gap instead of sleeping through one.
725    /// The seam matters more here than there — this half of the rule is the part
726    /// that hit-tests, and the frame around the editor was counted as buffer
727    /// until it could be asserted against.
728    ///
729    /// [`ClickRun`]: crate::app_screen::click_run::ClickRun
730    fn track_click(&mut self, event: &InputEvent, now: std::time::Instant) -> bool {
731        let InputEvent::Mouse(mouse) = event else {
732            self.clicks.end();
733            return false;
734        };
735        if self.overlays.is_open() {
736            self.clicks.end();
737            return false;
738        }
739        // Only a press consults the hit-test; `observe` discards the flag for
740        // every other kind. Motion is the reason to care — any-event tracking
741        // delivers one per cell of travel, and this would otherwise scan the
742        // column rects twice for each.
743        let on_buffer = matches!(
744            mouse.kind,
745            ratatui::crossterm::event::MouseEventKind::Down(_)
746        ) && self.panels.is_note_buffer_cell(mouse.column, mouse.row);
747        self.clicks.observe(mouse, on_buffer, now)
748    }
749
750    /// Apply a classification: pre-effects first (footer chord flash, leader
751    /// cancel), then the intent. The classifier decides *what* an event
752    /// means; everything that mutates happens here.
753    fn apply_classification(
754        &mut self,
755        classification: Classification,
756        event: &InputEvent,
757        tx: &AppTx,
758    ) -> EventState {
759        if let Some(chord) = classification.flash {
760            self.footer.flash(chord, tx);
761        }
762        if classification.cancel_leader {
763            self.leader.cancel();
764        }
765        self.execute_intent(classification.intent, event, tx)
766    }
767
768    fn execute_intent(
769        &mut self,
770        intent: EditorIntent,
771        event: &InputEvent,
772        tx: &AppTx,
773    ) -> EventState {
774        match intent {
775            EditorIntent::Consume => EventState::Consumed,
776            EditorIntent::EditorPaste => {
777                if !self.try_paste_image(tx)
778                    && let InputEvent::Paste(text) = event
779                    && !text.is_empty()
780                    && let Some(ed) = self.panels.editor_mut()
781                {
782                    ed.paste_text(text, tx);
783                }
784                EventState::Consumed
785            }
786            EditorIntent::ImageProbe => {
787                if self.try_paste_image(tx) {
788                    EventState::Consumed
789                } else {
790                    // No image: the rest of the ladder decides. Reclassify
791                    // the tail against fresh state — any leader cancel was
792                    // already applied by the probe classification, so the
793                    // tail must not re-cancel (`cancel_leader = false`).
794                    // A key path (Ctrl+V): no press to pair, so no double.
795                    let ctx = self.input_ctx(false);
796                    let classification = {
797                        let s = self.settings.read().unwrap();
798                        crate::app_screen::editor_input::classify_tail(
799                            event,
800                            &s.key_bindings,
801                            &ctx,
802                            false,
803                        )
804                    };
805                    self.apply_classification(classification, event, tx)
806                }
807            }
808            EditorIntent::FollowLink => {
809                if self.follow_link_at_cursor(tx) {
810                    EventState::Consumed
811                } else if matches!(event, InputEvent::Mouse(_)) {
812                    // Nothing under the cursor to follow, and this arrived as a
813                    // press. Swallowing it would make the second click of a
814                    // double a silent no-op anywhere but on a link, and would
815                    // claim the gesture buffer-wide for a follow that did not
816                    // happen. Hand it back to the panels as the press it is —
817                    // the same fallback-on-runtime-outcome shape `ImageProbe`
818                    // uses when the clipboard turns out to hold no image.
819                    self.execute_intent(EditorIntent::Mouse, event, tx)
820                } else {
821                    EventState::Consumed
822                }
823            }
824            EditorIntent::LeaderKey(key) => self.handle_leader_key(&key, tx),
825            EditorIntent::LeaderStart => {
826                self.leader.start();
827                self.schedule_whichkey_reveal(tx);
828                EventState::Consumed
829            }
830            EditorIntent::Op(op) => {
831                self.run_op(op, tx);
832                EventState::Consumed
833            }
834            EditorIntent::ToggleOverlay { kind, open } => {
835                if self.overlays.active_kind() == Some(kind) {
836                    self.dismiss_overlay();
837                } else {
838                    self.open_overlay(open, tx);
839                }
840                EventState::Consumed
841            }
842            EditorIntent::OpenOverlay(open) => {
843                self.open_overlay(open, tx);
844                EventState::Consumed
845            }
846            EditorIntent::Overlay => self.overlays.handle_input(event, tx),
847            EditorIntent::Mouse => {
848                // `PanelSet` hit-tests the panel columns: a click focuses the
849                // panel under the cursor (one rule for every panel) and the
850                // event is forwarded to that panel for its internal behavior.
851                let state = self.panels.handle_mouse(event, tx);
852                // Ask clicks (turn select, citation) move the drawer's Sources.
853                self.ask.sync_sources(&mut self.panels, tx);
854                // A selectionless right-click in the editor asks for the
855                // note's context menu — the screen owns the path, so it
856                // opens it here.
857                if self.panels.editor().is_some_and(|e| e.wants_context_menu) {
858                    if let Some(ed) = self.panels.editor_mut() {
859                        ed.wants_context_menu = false;
860                    }
861                    tx.send(AppEvent::FileOp(FileOp::ShowMenu(self.path.clone())))
862                        .ok();
863                }
864                state
865            }
866            EditorIntent::Panel { fallback } => {
867                let state = self.panels.handle_input(event, tx);
868                // Ask keys (j/k select, i/composer, submit) move the drawer's
869                // Sources in step with the thread's selected turn.
870                self.ask.sync_sources(&mut self.panels, tx);
871                if state == EventState::NotConsumed {
872                    match fallback {
873                        PanelFallback::None => state,
874                        PanelFallback::FocusCycle(CycleDir::Right) => {
875                            self.focus_right(tx);
876                            EventState::Consumed
877                        }
878                        PanelFallback::FocusCycle(CycleDir::Left) => {
879                            self.focus_left(tx);
880                            EventState::Consumed
881                        }
882                        PanelFallback::FocusEditor => {
883                            self.focus_editor();
884                            EventState::Consumed
885                        }
886                    }
887                } else {
888                    state
889                }
890            }
891        }
892    }
893
894    fn run_op(&mut self, op: EditorOp, tx: &AppTx) {
895        match op {
896            EditorOp::ToggleDrawer => self.toggle_drawer(tx),
897            EditorOp::FocusLeft => self.focus_left(tx),
898            EditorOp::FocusRight => self.focus_right(tx),
899            EditorOp::OpenJournal => {
900                tx.send(AppEvent::OpenJournal).ok();
901            }
902            EditorOp::ShowFileOps => {
903                tx.send(AppEvent::FileOp(FileOp::ShowMenu(self.path.clone())))
904                    .ok();
905            }
906            EditorOp::ToggleQueryPanel => self.toggle_backlinks(tx),
907            EditorOp::OpenFileBrowserReveal => {
908                self.open_drawer_view(DrawerView::Files, tx);
909                self.reveal_note_dir_in_sidebar(tx);
910            }
911            EditorOp::SaveCurrentQuery => {
912                if let Some((query, provenance, source)) = self.save_query_source() {
913                    // Opening the save dialog replaces the note browser (if
914                    // any); the chained-open guard preserves the original
915                    // opener focus.
916                    self.present_overlay(Box::new(ActiveDialog::save_search(
917                        query,
918                        provenance,
919                        source,
920                        self.vault.clone(),
921                        tx,
922                    )));
923                }
924            }
925            EditorOp::FindInBuffer => {
926                if let Some(ed) = self.panels.editor_mut() {
927                    ed.open_or_advance_search();
928                }
929            }
930            EditorOp::ReplaceInBuffer => {
931                if let Some(ed) = self.panels.editor_mut() {
932                    ed.open_replace();
933                }
934            }
935            EditorOp::ApplyText(text_action) => {
936                if let Some(ed) = self.panels.editor_mut() {
937                    ed.apply_text_action(text_action);
938                }
939            }
940            EditorOp::OpenAsk => self.open_ask_workspace(tx),
941        }
942    }
943
944    /// The one door for every overlay the screen can open: guard (no overlay
945    /// over an open overlay), build the recipe, present it. Opens that need
946    /// more than construction (the Ask workspace's capability gate, drawer
947    /// views) are not overlays and keep their own methods.
948    fn open_overlay(&mut self, open: OverlayOpen, tx: &AppTx) {
949        if self.overlays.is_open() {
950            return;
951        }
952        let overlay = self.build_overlay(open, tx);
953        self.present_overlay(overlay);
954    }
955
956    /// The note the editor area is showing, if any — what every "+this
957    /// note" leader action (`m r`/`m m`/`m d`/`m c`/`m y`/`m i`) acts on.
958    /// `None` on the vault root, in the attachment view (`self.path` is
959    /// then the attachment's path, never a note) and while the Ask
960    /// workspace is up (`self.path` still names the note it replaced, but
961    /// the user is not looking at it). The one definition of "this note",
962    /// so the m-group cannot disagree about it.
963    fn open_note(&self) -> Option<&VaultPath> {
964        if self.panels.is_showing_ask() || !self.path.is_note() {
965            None
966        } else {
967            Some(&self.path)
968        }
969    }
970
971    /// [`Self::open_note`], flashing "no note open" when there is none.
972    fn open_note_or_flash(&self, tx: &AppTx) -> Option<VaultPath> {
973        let note = self.open_note().cloned();
974        if note.is_none() {
975            tx.send(AppEvent::FlashMessage("no note open".into())).ok();
976        }
977        note
978    }
979
980    /// Leader `m i`: pin the open note, or unpin it if pinned. Refused with
981    /// a flash when nothing is open (see [`Self::open_note`]), when the
982    /// open note is no longer on disk, or when the vault already holds the
983    /// cap.
984    fn toggle_pin_open_note(&self, tx: &AppTx) {
985        let Some(path) = self.open_note_or_flash(tx) else {
986            return;
987        };
988        let vault = self.vault.clone();
989        let tx = tx.clone();
990        tokio::spawn(async move {
991            let msg = match vault.toggle_pinned_note(&path).await {
992                Ok(kimun_core::PinToggle::Pinned { position }) => format!(
993                    "pinned as {} of {}",
994                    position + 1,
995                    kimun_core::PINNED_NOTES_CAP
996                ),
997                Ok(kimun_core::PinToggle::Unpinned) => "unpinned".to_string(),
998                Ok(kimun_core::PinToggle::Full) => format!(
999                    "already {} pinned — unpin one first",
1000                    kimun_core::PINNED_NOTES_CAP
1001                ),
1002                Err(VaultError::FSError(FSError::VaultPathNotFound { .. })) => {
1003                    format!("note not found: {path}")
1004                }
1005                Err(e) => {
1006                    tracing::warn!("failed to toggle pin on {path}: {e}");
1007                    format!("could not update pinned notes: {e}")
1008                }
1009            };
1010            tx.send(AppEvent::FlashMessage(msg)).ok();
1011        });
1012    }
1013
1014    /// Leader digit `n`: open pinned note `n` (1-based). A slot with no pin,
1015    /// or a pin whose note is missing on disk, flashes instead.
1016    fn jump_to_pinned(&self, n: u8, tx: &AppTx) {
1017        let vault = self.vault.clone();
1018        let tx = tx.clone();
1019        tokio::spawn(async move {
1020            let pins = match vault.list_pinned_notes().await {
1021                Ok(pins) => pins,
1022                Err(e) => {
1023                    tx.send(AppEvent::FlashMessage(format!(
1024                        "could not read pinned notes: {e}"
1025                    )))
1026                    .ok();
1027                    return;
1028                }
1029            };
1030            let Some(path) = pins.get(usize::from(n).saturating_sub(1)).cloned() else {
1031                tx.send(AppEvent::FlashMessage(format!("no pinned note {n}")))
1032                    .ok();
1033                return;
1034            };
1035            if !vault.exists(&path).await {
1036                tx.send(AppEvent::FlashMessage(format!(
1037                    "pinned note not found: {path}"
1038                )))
1039                .ok();
1040                return;
1041            }
1042            tx.send(AppEvent::OpenPath {
1043                path,
1044                emphasis: None,
1045            })
1046            .ok();
1047        });
1048    }
1049
1050    /// Construction recipes for [`OverlayOpen`] — the single site answering
1051    /// "what overlays exist and how is each built". Reads screen state (vault,
1052    /// settings, open note, panel sort/order seeds) but never mutates it;
1053    /// presentation and the open-guard live in [`Self::open_overlay`].
1054    fn build_overlay(&self, open: OverlayOpen, tx: &AppTx) -> Box<dyn Overlay> {
1055        let s = self.settings.read().unwrap();
1056        match open {
1057            // The note-browser modal over the full-text search provider
1058            // (Ctrl-K and the leader's find paths).
1059            OverlayOpen::SearchBrowser => {
1060                let provider = resolving_search_source(
1061                    self.vault.clone(),
1062                    s.current_last_paths(),
1063                    Some(self.path.clone()),
1064                );
1065                Box::new(NoteBrowserModal::new(
1066                    "Note Browser",
1067                    BrowserScope::Query,
1068                    provider,
1069                    self.vault.clone(),
1070                    s.key_bindings.clone(),
1071                    s.icons(),
1072                    tx.clone(),
1073                ))
1074            }
1075            // The note-browser modal over the fuzzy file finder (Ctrl-O and
1076            // the leader's `f f`).
1077            OverlayOpen::FileFinder => {
1078                let current_dir = self.path.get_parent_path().0;
1079                let provider = FileFinderProvider::new(self.vault.clone(), current_dir);
1080                Box::new(NoteBrowserModal::new(
1081                    "Find Note",
1082                    BrowserScope::Files,
1083                    provider,
1084                    self.vault.clone(),
1085                    s.key_bindings.clone(),
1086                    s.icons(),
1087                    tx.clone(),
1088                ))
1089            }
1090            // F3 and leader `f s`.
1091            OverlayOpen::SavedSearches => Box::new(SavedSearchesModal::new(
1092                self.vault.clone(),
1093                s.key_bindings.clone(),
1094                s.icons(),
1095                tx.clone(),
1096            )),
1097            // Ctrl+Shift+P and leader `p`.
1098            OverlayOpen::CommandPalette => {
1099                let gateway = s
1100                    .key_bindings
1101                    .first_combo_for(&ActionShortcuts::Leader)
1102                    .unwrap_or_else(|| "leader".to_string());
1103                Box::new(
1104                    crate::components::command_palette::CommandPaletteModal::new(
1105                        &s.leader_tree(),
1106                        &gateway,
1107                        s.icons(),
1108                        tx.clone(),
1109                    ),
1110                )
1111            }
1112            // SwitchWorkspace action and leader `v s`.
1113            OverlayOpen::WorkspaceSwitcher => Box::new(ActiveDialog::workspace_switcher(&s)),
1114            // Leader `v t` and inside CFG via `t`; the full settings screen
1115            // stays on the OpenSettings binding.
1116            OverlayOpen::ThemePicker => Box::new(ActiveDialog::theme_picker(&s)),
1117            OverlayOpen::Help => Box::new(ActiveDialog::help(&s.key_bindings, &s.leader_tree())),
1118            OverlayOpen::QueryHelp => Box::new(ActiveDialog::query_syntax()),
1119            OverlayOpen::Cheatsheet => Box::new(ActiveDialog::cheatsheet(&s)),
1120            OverlayOpen::SortQuery => {
1121                let (field, order) = self.panels.query().current_order();
1122                Box::new(ActiveDialog::sort(SortTarget::Query, field, order, false))
1123            }
1124            OverlayOpen::SortSidebar => {
1125                let (field, order) = self.panels.sidebar().current_sort();
1126                Box::new(ActiveDialog::sort(
1127                    SortTarget::Sidebar,
1128                    field,
1129                    order,
1130                    self.panels.sidebar().group_dirs(),
1131                ))
1132            }
1133            OverlayOpen::QuickNote => Box::new(ActiveDialog::quick_note(self.vault.clone())),
1134            // Leader `f p`.
1135            OverlayOpen::PinnedNotes => {
1136                Box::new(ActiveDialog::pinned_notes(self.vault.clone(), tx))
1137            }
1138        }
1139    }
1140
1141    /// One owner for the self-update lifecycle's display half; the
1142    /// app-global half (persisting dismissals, seeding later screens) lives
1143    /// in the App loop (`app::handle_app_message`).
1144    fn handle_update(&mut self, flow: UpdateFlow, tx: &AppTx) {
1145        match flow {
1146            UpdateFlow::Available(status) => {
1147                self.update = Some(status);
1148            }
1149            UpdateFlow::ShowDialog => {
1150                if let Some(status) = self.update.clone() {
1151                    self.present_overlay(Box::new(ActiveDialog::update(&status)));
1152                }
1153            }
1154            UpdateFlow::Dismiss(_) => {
1155                // Persistence happens in main; here we just drop the indicator.
1156                self.update = None;
1157            }
1158            UpdateFlow::Applied => {
1159                // Installed — drop the notice so the footer/dialog stop offering
1160                // the version we just wrote (restart still required to run it).
1161                self.update = None;
1162            }
1163            UpdateFlow::Apply => {
1164                let tx2 = tx.clone();
1165                tx.send(AppEvent::FlashMessage("Downloading update…".into()))
1166                    .ok();
1167                tokio::spawn(async move {
1168                    let result = async {
1169                        let latest = crate::update::latest_release().await?;
1170                        crate::update::install(latest).await
1171                    }
1172                    .await;
1173                    let msg = match result {
1174                        Ok(()) => {
1175                            tx2.send(AppEvent::Update(UpdateFlow::Applied)).ok();
1176                            "Update installed — restart kimün to apply".to_string()
1177                        }
1178                        Err(e) => format!("Update failed: {e}"),
1179                    };
1180                    tx2.send(AppEvent::FlashMessage(msg)).ok();
1181                });
1182            }
1183        }
1184    }
1185
1186    /// One owner for file operations: requests present the matching dialog,
1187    /// confirmations keep the sidebar and the open note in step.
1188    async fn handle_file_op(&mut self, op: FileOp, tx: &AppTx) {
1189        match op {
1190            FileOp::ShowMenu(path) => {
1191                self.present_overlay(Box::new(ActiveDialog::file_ops_menu(path)));
1192            }
1193            FileOp::ShowDelete(path) => {
1194                self.present_overlay(Box::new(ActiveDialog::delete(path, self.vault.clone())));
1195            }
1196            FileOp::ShowRename(path) => {
1197                self.present_overlay(Box::new(ActiveDialog::rename(path, self.vault.clone())));
1198            }
1199            FileOp::ShowMove(path) => {
1200                self.present_overlay(Box::new(ActiveDialog::move_to(
1201                    path,
1202                    self.vault.clone(),
1203                    tx,
1204                )));
1205            }
1206            FileOp::ShowCreateWithContent { path, content } => {
1207                // The suggested path is derived from a title (an Ask question),
1208                // so unlike the other create-note entry points it is not known
1209                // to be free — and the create behind this dialog avoids
1210                // conflicts by incrementing the name. Resolve that name here so
1211                // the dialog shows the note the user is actually confirming,
1212                // instead of promising `ask/foo.md` and quietly writing
1213                // `ask/foo_0.md`.
1214                let path = self.vault.free_note_path(&path).await;
1215                self.present_overlay(Box::new(ActiveDialog::create_note(
1216                    path,
1217                    self.vault.clone(),
1218                    Some(content),
1219                )));
1220            }
1221            FileOp::Created(path) => {
1222                // Pure notification: a note now exists at `path`. Opening is the
1223                // creator's job (via OpenPath); here we only keep the sidebar in
1224                // step when it is browsing the new note's directory.
1225                self.refresh_sidebar_if_showing(&path.get_parent_path().0, tx);
1226            }
1227            FileOp::Deleted(path) => {
1228                self.on_entry_op(path, tx).await;
1229            }
1230            FileOp::Renamed { from, to } => {
1231                // Note rename → targeted row update (and retarget the editor if
1232                // it is the open note). Directory rename keeps the full reload.
1233                if from.is_note() {
1234                    self.on_note_renamed(from, to, tx).await;
1235                } else {
1236                    self.on_entry_op(from, tx).await;
1237                }
1238            }
1239            FileOp::Moved { from, .. } => {
1240                self.on_entry_op(from, tx).await;
1241            }
1242        }
1243    }
1244
1245    /// One owner for the saved-search save/select flow.
1246    fn handle_saved_search(&mut self, flow: SavedSearchFlow, tx: &AppTx) {
1247        match flow {
1248            SavedSearchFlow::Selected { query, name } => {
1249                // Deliberate non-restoring close: the selection lands in the
1250                // Query panel (set by apply_saved_search), not back on the
1251                // overlay's opener — so close the overlay without dismiss_overlay.
1252                self.overlays.close();
1253                self.apply_saved_search(query, name, tx);
1254            }
1255            SavedSearchFlow::Confirmed {
1256                name,
1257                query,
1258                source,
1259            } => {
1260                // Write in the background; the breadcrumb re-pin waits for
1261                // the success event so the UI never claims an unpersisted
1262                // save (see `Persisted` below).
1263                let vault = self.vault.clone();
1264                let tx = tx.clone();
1265                tokio::spawn(async move {
1266                    match vault.save_search(&name, &query).await {
1267                        Ok(()) => {
1268                            tx.send(AppEvent::SavedSearch(SavedSearchFlow::Persisted {
1269                                name,
1270                                query,
1271                                source,
1272                            }))
1273                            .ok();
1274                        }
1275                        Err(e) => {
1276                            tracing::warn!("failed to save search '{}': {}", name, e);
1277                            tx.send(AppEvent::SavedSearch(SavedSearchFlow::SaveFailed { name }))
1278                                .ok();
1279                        }
1280                    }
1281                });
1282            }
1283            // Re-pin the panel breadcrumb to the saved identity: the edited
1284            // marker drops on an update, the name switches on a save-as-new.
1285            // Only for panel-sourced saves (a note-browser save must not
1286            // steal the panel's provenance, even when the query text
1287            // coincides), and not for the query-as-name fallback (a
1288            // breadcrumb that echoes the query is noise).
1289            SavedSearchFlow::Persisted {
1290                name,
1291                query,
1292                source: SaveSource::QueryPanel,
1293            } if name.trim() != query.trim() => {
1294                self.panels.query_mut().repin_saved_search(name, &query);
1295            }
1296            SavedSearchFlow::Persisted { .. } => {}
1297            SavedSearchFlow::SaveFailed { name } => {
1298                self.footer
1299                    .flash(format!("Failed to save search '{name}'"), tx);
1300            }
1301        }
1302    }
1303
1304    /// The editor-owned singles: everything with exactly one arm and no
1305    /// family. Family events never reach this match.
1306    async fn handle_owned_message(&mut self, msg: AppEvent, tx: &AppTx) {
1307        match msg {
1308            AppEvent::RagStatus(status) => {
1309                let ask_was = self.rag_status.llm_available();
1310                let sem_was = self.rag_status.search_available();
1311                self.rag_status = status;
1312                // Both rail entries are live-status-driven: ASK on whether the
1313                // server can answer questions, SEM on whether it can search.
1314                // Rebuild the rail when either flips (an active view
1315                // stays put when its capability drops — only the rail entry
1316                // goes). The Ask client only needs refreshing on the ASK flip.
1317                let ask_now = status.llm_available();
1318                let sem_now = status.search_available();
1319                if ask_was != ask_now || sem_was != sem_now {
1320                    let (kb, icons) = {
1321                        let s = self.settings.read().unwrap();
1322                        (s.key_bindings.clone(), s.icons())
1323                    };
1324                    self.panels.rebuild_rail(
1325                        kb,
1326                        icons,
1327                        crate::components::activity_rail::RailCaps {
1328                            semantic: sem_now,
1329                            ask: ask_now,
1330                        },
1331                    );
1332                }
1333                if ask_was != ask_now {
1334                    self.ask
1335                        .refresh_capability(&mut self.panels, self.rag_status)
1336                        .await;
1337                }
1338            }
1339            AppEvent::Ask(data) => self.ask.handle_data(&mut self.panels, data, tx),
1340            // Stale completions (for notes we've navigated away from) fall
1341            // through to the catch-all and are dropped.
1342            AppEvent::FlashMessage(msg) => {
1343                self.footer.flash(msg, tx);
1344            }
1345            AppEvent::ExecuteLeaderAction(action) => {
1346                if self.overlays.is_open() {
1347                    // The palette closes itself before sending, so this is
1348                    // unreachable from it — but never drop an action silently.
1349                    tracing::warn!("ExecuteLeaderAction({action:?}) dropped: overlay open");
1350                } else {
1351                    self.execute_leader_action(action, tx);
1352                }
1353            }
1354            AppEvent::ApplyTheme { theme, persist } => {
1355                // The picker resolved the theme already — no disk re-read,
1356                // just adapt to the terminal and swap.
1357                {
1358                    let mut s = self.settings.write().unwrap();
1359                    s.set_theme(theme.name.clone());
1360                }
1361                self.theme = (*theme).adapt_to_terminal();
1362                if persist {
1363                    let snapshot = self.settings.read().unwrap().clone();
1364                    tokio::spawn(async move {
1365                        snapshot.save_to_disk().ok();
1366                    });
1367                }
1368                tx.send(AppEvent::Redraw).ok();
1369            }
1370            // Drawer panels can't emit these under an overlay, but guard
1371            // anyway: never mutate panels while an overlay owns input.
1372            AppEvent::RunTagQuery(label) if !self.overlays.is_open() => {
1373                self.open_find_with_query(format!("#{label}"), None, tx);
1374            }
1375            AppEvent::JumpToHeading(heading) if !self.overlays.is_open() => {
1376                if let Some(ed) = self.panels.editor_mut() {
1377                    ed.jump_to_heading(&heading);
1378                }
1379                self.focus_editor();
1380            }
1381            AppEvent::OpenDrawerView(view) => {
1382                // The rail hides SEM unless the server is reachable for search,
1383                // but the leader path (`drawer.semantic`) can still request it —
1384                // gate here so every route gets the same answer.
1385                if view == DrawerView::Semantic && !self.rag_status.search_available() {
1386                    let msg = if crate::rag::rag_configured(&self.settings) {
1387                        "Semantic search needs a reachable server with an embedder"
1388                    } else {
1389                        "Set kimun_server_url in config to use semantic search"
1390                    };
1391                    tx.send(AppEvent::FlashMessage(msg.into())).ok();
1392                }
1393                // ASK needs an LLM-configured server (the rail hides its entry
1394                // otherwise); gate every route the same way.
1395                else if view == DrawerView::Ask && !self.rag_status.llm_available() {
1396                    tx.send(AppEvent::FlashMessage(
1397                        "Ask needs an LLM-configured server; this one is semantic-search only"
1398                            .into(),
1399                    ))
1400                    .ok();
1401                }
1402                // Selecting the already-active view toggles the drawer closed
1403                // (spec §3: clicking the active rail item toggles).
1404                else if self.panels.is_visible(PanelKind::Drawer)
1405                    && self.panels.active_drawer_view() == view
1406                {
1407                    self.panels.hide(PanelKind::Drawer);
1408                } else {
1409                    self.open_drawer_view(view, tx);
1410                }
1411            }
1412            AppEvent::CloseOverlay => {
1413                // Dismiss-to-opener. Guarded by is_open() on purpose: a
1414                // selection that wants a specific post-close focus
1415                // (OpenPath -> editor, SavedSearchSelected -> Query panel)
1416                // closes the overlay itself first, so a later/!dialog
1417                // CloseOverlay must not re-restore and clobber that focus.
1418                self.dismiss_overlay();
1419            }
1420            AppEvent::SortChanged {
1421                target,
1422                field,
1423                order,
1424                group_directories,
1425                persist,
1426            } => {
1427                match target {
1428                    SortTarget::Sidebar if persist => {
1429                        // Update the sidebar's in-session per-context default AND
1430                        // apply live. `is_current_journal()` is the single source
1431                        // of truth for which context this save targets — reused
1432                        // for the on-disk settings write below.
1433                        let is_journal = self.panels.sidebar().is_current_journal();
1434                        self.panels
1435                            .sidebar_mut()
1436                            .save_default(field, order, group_directories);
1437                        {
1438                            let mut s = self.settings.write().unwrap();
1439                            if is_journal {
1440                                s.journal_sort_field =
1441                                    crate::settings::SortFieldSetting::from(field);
1442                                s.journal_sort_order =
1443                                    crate::settings::SortOrderSetting::from(order);
1444                            } else {
1445                                s.default_sort_field =
1446                                    crate::settings::SortFieldSetting::from(field);
1447                                s.default_sort_order =
1448                                    crate::settings::SortOrderSetting::from(order);
1449                            }
1450                            s.group_directories = group_directories;
1451                        }
1452                        let snapshot = self.settings.read().unwrap().clone();
1453                        tokio::spawn(async move {
1454                            snapshot.save_to_disk().ok();
1455                        });
1456                    }
1457                    SortTarget::Sidebar => {
1458                        self.panels
1459                            .sidebar_mut()
1460                            .apply_sort(field, order, group_directories)
1461                    }
1462                    // The query panel has no persisted default (the order lives
1463                    // in the query string); `persist` is always false here.
1464                    SortTarget::Query => self.panels.query_mut().apply_sort(field, order, tx),
1465                }
1466            }
1467            AppEvent::Autosave => {
1468                self.spawn_autosave(tx);
1469            }
1470            AppEvent::AutosaveCompleted {
1471                path,
1472                saved_revision,
1473                title,
1474            } => {
1475                if path == self.path
1476                    && let Some(rev) = saved_revision
1477                    && let Some(ed) = self.panels.editor_mut()
1478                {
1479                    ed.mark_saved_at_revision(rev);
1480                }
1481                if let Some(raw_title) = title {
1482                    self.note_saved(&path, raw_title);
1483                }
1484                // The write changed the working tree — refresh the git
1485                // segment (throttled).
1486                self.doc_meta.refresh_git(tx);
1487                // `SingleSlotTask::is_in_flight()` flips to false the
1488                // moment the spawned future returns (success or panic),
1489                // so we don't have to clear the slot manually here —
1490                // the next `spawn_autosave` tick will overwrite it.
1491                // Skip explicit cleanup; was previously racy because a
1492                // stale completion arriving after `try_save` had
1493                // already cleared and respawned could wipe the fresh
1494                // handle.
1495            }
1496            AppEvent::FocusSidebar => {
1497                self.focus_sidebar(tx);
1498            }
1499            AppEvent::FollowLink(target) => {
1500                self.follow_link(target, tx).await;
1501            }
1502            AppEvent::FollowLabel(name) => {
1503                let initial = format!("#{name}");
1504                let s = self.settings.read().unwrap();
1505                let provider = resolving_search_source(
1506                    self.vault.clone(),
1507                    s.current_last_paths(),
1508                    Some(self.path.clone()),
1509                );
1510                let modal = NoteBrowserModal::with_initial_query(
1511                    "Note Browser",
1512                    BrowserScope::Query,
1513                    provider,
1514                    self.vault.clone(),
1515                    s.key_bindings.clone(),
1516                    s.icons(),
1517                    tx.clone(),
1518                    initial,
1519                );
1520                drop(s);
1521                self.present_overlay(Box::new(modal));
1522            }
1523            AppEvent::InsertAtCursor(text) if self.panels.focused() == PanelKind::Editor => {
1524                if let Some(ed) = self.panels.editor_mut() {
1525                    ed.insert_at_cursor(&text, tx);
1526                }
1527            }
1528            _ => {}
1529        }
1530    }
1531
1532    pub fn focus_editor(&mut self) {
1533        self.panels.focus(PanelKind::Editor);
1534    }
1535
1536    /// Whether the drawer is open showing `view`.
1537    fn drawer_open_on(&self, view: DrawerView) -> bool {
1538        self.panels.is_visible(PanelKind::Drawer) && self.panels.active_drawer_view() == view
1539    }
1540
1541    /// Reflect the currently-open note (`self.path`) into whichever drawer view
1542    /// is visible (FIND/LINKS/OUTLINE). Shared by `open_path` and
1543    /// `on_note_renamed` so a rename keeps the drawers in step, not just a
1544    /// fresh open.
1545    fn reflect_open_note_in_drawers(&mut self, tx: &AppTx) {
1546        let path = self.path.clone();
1547        if self.drawer_open_on(DrawerView::Find) {
1548            self.panels.query_mut().set_note(path.clone(), tx.clone());
1549        }
1550        if self.panels.is_visible(PanelKind::Drawer) {
1551            match self.panels.active_drawer_view() {
1552                DrawerView::Links => self.panels.links_mut().set_note(path.clone(), tx),
1553                DrawerView::Outline => self.panels.outline_mut().set_note(path, tx),
1554                _ => {}
1555            }
1556        }
1557    }
1558
1559    /// Point the sidebar at the current note's directory. Skips the engine
1560    /// rebuild when the sidebar is already there so an in-progress filter
1561    /// and selection survive a re-open.
1562    fn reveal_note_dir_in_sidebar(&mut self, tx: &AppTx) {
1563        let note_parent = self.path.get_parent_path().0;
1564        if self.panels.sidebar().is_empty()
1565            || !note_parent.is_like(self.panels.sidebar().current_dir())
1566        {
1567            self.navigate_sidebar(note_parent, tx);
1568        }
1569    }
1570
1571    /// Focus the drawer, revealing it on FILES if hidden — but never clobber
1572    /// the view the user already has open (e.g. a FIND query in progress).
1573    /// Sent by the nvim backend's leave-editor motions.
1574    pub fn focus_sidebar(&mut self, tx: &AppTx) {
1575        if !self.panels.is_visible(PanelKind::Drawer) {
1576            // Routed through the host opener so the FILES view reveals the
1577            // current note's directory like any other drawer open (it also
1578            // focuses the drawer).
1579            self.open_drawer_view(DrawerView::Files, tx);
1580        } else {
1581            self.panels.focus(PanelKind::Drawer);
1582        }
1583    }
1584
1585    /// Switch the drawer to `view`, reveal it, and focus it. The per-view
1586    /// reveal side effects live in `drawer_view_revealed` (the heavy work
1587    /// that keeps the reveal in the host rather than in `PanelSet`).
1588    fn open_drawer_view(&mut self, view: DrawerView, tx: &AppTx) {
1589        // Keep the editor-area Ask content in lockstep with the drawer view:
1590        // entering ASK restores the stashed thread; leaving it stashes the
1591        // live one (rules 1 & 2 — thread survives every view switch).
1592        self.ask.transition(&mut self.panels, view, tx);
1593        let newly_shown = !self.drawer_open_on(view);
1594        self.panels.open_drawer_view(view);
1595        self.drawer_view_revealed(view, newly_shown, tx);
1596        // The ASK workspace lives in the editor area; drop the user on the
1597        // composer rather than the Sources drawer. Every other view focuses
1598        // the drawer as before.
1599        if view == DrawerView::Ask {
1600            self.panels.focus(PanelKind::Editor);
1601        } else {
1602            self.panels.focus(PanelKind::Drawer);
1603        }
1604    }
1605
1606    /// Ensure the Ask workspace is the editor-area content, for the leader
1607    /// `a` conversation actions. Ungated: managing an existing thread works
1608    /// even when the server can't answer (the composer stays disabled).
1609    fn ensure_ask_workspace(&mut self, tx: &AppTx) {
1610        if !self.panels.is_showing_ask() {
1611            self.open_drawer_view(DrawerView::Ask, tx);
1612        }
1613    }
1614
1615    /// Ensure the Ask workspace is showing, then run `f` against its live
1616    /// panel — the shared shape behind the leader `a n`/`a y`/`a e`/`a r`
1617    /// conversation actions.
1618    fn with_ask_panel(&mut self, tx: &AppTx, f: impl FnOnce(&mut ThreadPanel, &AppTx)) {
1619        self.ensure_ask_workspace(tx);
1620        f(self.panels.ask_mut(), tx);
1621    }
1622
1623    /// Per-view side effects to run when `view` becomes visible in the
1624    /// drawer — the single table shared by every reveal path (rail/leader
1625    /// opens, the Ctrl-T toggle restore) so a view cannot go stale on one
1626    /// path and refresh on another. `newly_shown` gates the effects that
1627    /// must not clobber state the user already has on screen (an
1628    /// in-progress FIND query, a browsed sidebar directory); the rest
1629    /// refresh unconditionally. Never touches focus — callers decide that.
1630    fn drawer_view_revealed(&mut self, view: DrawerView, newly_shown: bool, tx: &AppTx) {
1631        match view {
1632            DrawerView::Find if newly_shown => {
1633                self.panels
1634                    .query_mut()
1635                    .set_note(self.path.clone(), tx.clone());
1636            }
1637            DrawerView::Files if newly_shown => {
1638                self.reveal_note_dir_in_sidebar(tx);
1639            }
1640            DrawerView::Config => {
1641                let info = {
1642                    let s = self.settings.read().unwrap();
1643                    let key_of = |a: &ActionShortcuts| {
1644                        s.key_bindings
1645                            .first_combo_for(a)
1646                            .unwrap_or_else(|| "unbound".to_string())
1647                    };
1648                    crate::components::drawer::ConfigInfo {
1649                        theme_name: s.get_theme().name,
1650                        leader_key: key_of(&ActionShortcuts::Leader),
1651                        preferences_key: key_of(&ActionShortcuts::OpenPreferences),
1652                        leader_timeout_ms: s.leader_timeout_ms,
1653                        config_path: s
1654                            .config_file
1655                            .as_ref()
1656                            .map(|p| p.display().to_string())
1657                            .unwrap_or_else(|| "default location".to_string()),
1658                    }
1659                };
1660                self.panels.drawer_set_config_info(info);
1661            }
1662            DrawerView::Semantic => self.panels.semantic_mut().ensure_source(tx),
1663            DrawerView::Tags => self.panels.tags_mut().refresh(tx),
1664            DrawerView::Links => self.panels.links_mut().set_note(self.path.clone(), tx),
1665            DrawerView::Outline => self.panels.outline_mut().set_note(self.path.clone(), tx),
1666            _ => {}
1667        }
1668    }
1669
1670    /// A `+text` leader leaf: apply the formatting, or say why not.
1671    ///
1672    /// One helper for the three leaves, and one rule — `InputCtx`'s, the
1673    /// same one the chord tier is held to by `apply_claim`. A pending leader
1674    /// sequence outranks a claim so the keys reach here even with the find
1675    /// bar open; the leaf itself still must not edit a buffer whose input
1676    /// belongs to the bar. The flash matters — a leader sequence that fires
1677    /// and does nothing visible reads as a broken binding, and this is the
1678    /// only route to formatting.
1679    fn apply_text_from_leader(&mut self, action: TextAction, tx: &AppTx) {
1680        if self.input_ctx(false).accepts_text_action() {
1681            self.run_op(EditorOp::ApplyText(action), tx);
1682        } else {
1683            self.footer
1684                .flash("formatting needs the note focused".to_string(), tx);
1685        }
1686    }
1687
1688    /// Move focus one visible panel left, wrapping at the end.
1689    fn focus_left(&mut self, _tx: &AppTx) {
1690        if let Some(kind) = self.panels.prev_kind() {
1691            self.panels.focus(kind);
1692        }
1693    }
1694
1695    /// Move focus one visible panel right, wrapping at the end.
1696    fn focus_right(&mut self, _tx: &AppTx) {
1697        if let Some(kind) = self.panels.next_kind() {
1698            self.panels.focus(kind);
1699        }
1700    }
1701
1702    /// Toggle the drawer (Ctrl-T): hiding it gives the full remaining width
1703    /// to the editor; showing it restores the last view. Restoring is a
1704    /// fresh reveal: the restored view re-targets/refreshes via the shared
1705    /// `drawer_view_revealed` table — the note (or its data) may have
1706    /// changed while hidden. Unlike an explicit open, toggling never moves
1707    /// focus into the drawer.
1708    fn toggle_drawer(&mut self, tx: &AppTx) {
1709        if self.panels.is_visible(PanelKind::Drawer) {
1710            self.panels.hide(PanelKind::Drawer);
1711        } else {
1712            self.panels.show(PanelKind::Drawer);
1713            self.drawer_view_revealed(self.panels.active_drawer_view(), true, tx);
1714        }
1715    }
1716
1717    /// The query the save-current-query action would save, with its
1718    /// saved-search provenance (breadcrumb name) for the dialog's name
1719    /// pre-fill. Sourced from the active note browser if one is open (Ctrl+K
1720    /// modal), otherwise from the Query panel. `None` when there is nothing
1721    /// to save: a blank query, or another overlay is open.
1722    fn save_query_source(&self) -> Option<(String, Option<String>, SaveSource)> {
1723        let (query, provenance, source) = match self.overlays.active_kind() {
1724            Some(OverlayKind::NoteBrowser) => (
1725                self.overlays.active_query().unwrap_or_default().to_string(),
1726                self.overlays
1727                    .active_saved_search_provenance()
1728                    .map(str::to_string),
1729                SaveSource::NoteBrowser,
1730            ),
1731            None => (
1732                self.panels.query().active_query().to_string(),
1733                self.panels.query().saved_search_name().map(str::to_string),
1734                SaveSource::QueryPanel,
1735            ),
1736            Some(_) => return None,
1737        };
1738        if query.trim().is_empty() {
1739            None
1740        } else {
1741            Some((query, provenance, source))
1742        }
1743    }
1744
1745    /// Schedule a redraw for when the which-key overlay should reveal: the
1746    /// hesitation timeout after the sequence (re)advanced. Fluent typing
1747    /// never sees the overlay; the timer redraw simply finds the sequence
1748    /// already gone.
1749    fn schedule_whichkey_reveal(&self, tx: &AppTx) {
1750        let timeout = {
1751            let s = self.settings.read().unwrap();
1752            std::time::Duration::from_millis(s.leader_timeout_ms)
1753        };
1754        let tx2 = tx.clone();
1755        tokio::spawn(async move {
1756            tokio::time::sleep(timeout + std::time::Duration::from_millis(10)).await;
1757            let _ = tx2.send(AppEvent::Redraw);
1758        });
1759    }
1760
1761    /// Switch to the Ask workspace and drop the cursor on the composer (the
1762    /// Ask shortcut / leader `a a`). The rail hides the ASK entry when the
1763    /// server can't answer questions, so this shortcut is the only remaining
1764    /// way in — hence the `llm_available` gate. The gate only
1765    /// applies to that enter-from-outside path: when Ask is already showing,
1766    /// focusing the composer is ungated, matching the sibling `a n`/`a
1767    /// y`/`a e`/`a r` actions, which work fine on a degraded (capability-off)
1768    /// thread.
1769    fn open_ask_workspace(&mut self, tx: &AppTx) {
1770        if self.overlays.is_open() {
1771            return;
1772        }
1773        if !self.panels.is_showing_ask() {
1774            if !self.rag_status.llm_available() {
1775                tx.send(AppEvent::FlashMessage(
1776                    "Ask needs an LLM-configured server; this one is semantic-search only".into(),
1777                ))
1778                .ok();
1779                return;
1780            }
1781            self.open_drawer_view(DrawerView::Ask, tx);
1782        }
1783        self.panels.ask_mut().focus_composer();
1784        self.panels.focus(PanelKind::Editor);
1785    }
1786
1787    /// Follow the **follow target** under the editor cursor (FollowLink action,
1788    /// Ctrl+Enter on kitty-protocol terminals).
1789    ///
1790    /// Returns whether anything was actually followed, so a caller that only
1791    /// *guessed* there was something there can fall back. A double-click is
1792    /// exactly that guess: the classifier cannot hit-test the buffer, so it
1793    /// asks and lets the answer decide.
1794    fn follow_link_at_cursor(&mut self, tx: &AppTx) -> bool {
1795        use crate::components::text_editor::FollowTarget;
1796        // In the attachment view, FollowLink (Ctrl+N) opens the attachment with
1797        // the OS default program rather than following a link (there is none).
1798        if self.panels.is_showing_attachment() {
1799            self.open_attachment_externally(tx);
1800            return true;
1801        }
1802        let Some(editor) = self.panels.editor_mut() else {
1803            return false;
1804        };
1805        match editor.follow_target_at_cursor() {
1806            Some(FollowTarget::Link(target)) => {
1807                tx.send(AppEvent::FollowLink(target)).ok();
1808                true
1809            }
1810            Some(FollowTarget::Label(name)) => {
1811                tx.send(AppEvent::FollowLabel(name)).ok();
1812                true
1813            }
1814            None => false,
1815        }
1816    }
1817
1818    /// Opens the attachment currently shown in the editor area with the OS
1819    /// default program (the same handoff `follow_link` uses for image links).
1820    fn open_attachment_externally(&mut self, tx: &AppTx) {
1821        let Some(path) = self.panels.attachment_path() else {
1822            return;
1823        };
1824        let os_path = self.vault.path_to_pathbuf(path);
1825        match open::that_detached(&os_path) {
1826            Ok(()) => self
1827                .footer
1828                .flash(format!("Opening {}", os_path.display()), tx),
1829            Err(e) => self.footer.flash(format!("Cannot open: {e}"), tx),
1830        }
1831    }
1832
1833    /// One key of a pending leader sequence. Esc cancels (focus returns to
1834    /// the editor), Backspace steps up, chars walk the tree, a fired leaf
1835    /// executes. Everything is consumed — a pending sequence owns the
1836    /// keyboard.
1837    fn handle_leader_key(
1838        &mut self,
1839        key: &ratatui::crossterm::event::KeyEvent,
1840        tx: &AppTx,
1841    ) -> EventState {
1842        use ratatui::crossterm::event::{KeyCode, KeyModifiers};
1843        match key.code {
1844            KeyCode::Esc => {
1845                self.leader.cancel();
1846                self.focus_editor();
1847            }
1848            KeyCode::Backspace => {
1849                let outcome = self.leader.step_up();
1850                // Stepping past the root cancels — no reveal to re-arm then.
1851                if outcome == LeaderOutcome::SteppedUp {
1852                    self.schedule_whichkey_reveal(tx);
1853                }
1854            }
1855            KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
1856                match self.leader.feed(c) {
1857                    LeaderOutcome::Fired(action) => self.execute_leader_action(action, tx),
1858                    LeaderOutcome::Invalid => {
1859                        // Gentle feedback; the sequence stays pending.
1860                        self.footer.flash(format!("leader: no entry for '{c}'"), tx);
1861                        self.schedule_whichkey_reveal(tx);
1862                    }
1863                    LeaderOutcome::Descended => self.schedule_whichkey_reveal(tx),
1864                    _ => {}
1865                }
1866            }
1867            // Other keys (arrows, function keys, …) are swallowed; the
1868            // sequence stays pending. Ctrl-chords never reach here — the
1869            // intercept in handle_input cancels and re-dispatches them.
1870            _ => {}
1871        }
1872        tx.send(AppEvent::Redraw).ok();
1873        EventState::Consumed
1874    }
1875
1876    /// Execute a fired leader leaf. Stubs for surfaces that land in later
1877    /// phases flash a "coming soon" notice instead of silently doing nothing.
1878    fn execute_leader_action(&mut self, action: LeaderAction, tx: &AppTx) {
1879        match action {
1880            LeaderAction::OpenDrawer(view) => self.open_drawer_view(view, tx),
1881
1882            LeaderAction::AppCheckUpdates => {
1883                if let Some(status) = self.update.clone() {
1884                    self.present_overlay(Box::new(ActiveDialog::update(&status)));
1885                } else {
1886                    // No cached notice — run a forced check and report the result.
1887                    let tx2 = tx.clone();
1888                    tx.send(AppEvent::FlashMessage("Checking for updates…".into()))
1889                        .ok();
1890                    tokio::spawn(async move {
1891                        let Ok(config_dir) = crate::settings::config_dir() else {
1892                            return;
1893                        };
1894                        match crate::update::check_now(config_dir, true).await {
1895                            Ok(Some(status)) if status.update_available => {
1896                                // Manual check: surface the notice AND open the
1897                                // dialog the user explicitly asked for (shown even
1898                                // if previously skipped — they asked).
1899                                tx2.send(AppEvent::Update(UpdateFlow::Available(status)))
1900                                    .ok();
1901                                tx2.send(AppEvent::Update(UpdateFlow::ShowDialog)).ok();
1902                            }
1903                            Ok(_) => {
1904                                tx2.send(AppEvent::FlashMessage("kimün is up to date".into()))
1905                                    .ok();
1906                            }
1907                            Err(e) => {
1908                                tx2.send(AppEvent::FlashMessage(format!(
1909                                    "Update check failed: {e}"
1910                                )))
1911                                .ok();
1912                            }
1913                        }
1914                    });
1915                }
1916            }
1917
1918            // +find — list-style leaves route to today's pickers; the
1919            // telescope modal takes them over in phase 08.
1920            LeaderAction::FindFiles => self.open_overlay(OverlayOpen::FileFinder, tx),
1921            LeaderAction::FindGrep => self.open_overlay(OverlayOpen::SearchBrowser, tx),
1922            LeaderAction::FindTags => self.open_drawer_view(DrawerView::Tags, tx),
1923            LeaderAction::FindBacklinks => {
1924                self.open_find_with_query("<{note}".to_string(), None, tx)
1925            }
1926            LeaderAction::FindSaved => self.open_overlay(OverlayOpen::SavedSearches, tx),
1927            LeaderAction::FindRecent => self.open_overlay(OverlayOpen::SearchBrowser, tx),
1928            LeaderAction::FindHeadings => self.open_drawer_view(DrawerView::Outline, tx),
1929
1930            // +note
1931            LeaderAction::NoteNew => {
1932                // The FILES filter doubles as the create field (typing a new
1933                // name offers "Create: …"); the telescope picker (08) gives
1934                // this a dedicated door.
1935                self.open_drawer_view(DrawerView::Files, tx);
1936                self.footer
1937                    .flash("type a name — Enter creates".to_string(), tx);
1938            }
1939            LeaderAction::NoteDaily => {
1940                tx.send(AppEvent::OpenJournal).ok();
1941            }
1942            LeaderAction::NoteFromTemplate => {
1943                self.footer.flash("templates — coming soon".to_string(), tx);
1944            }
1945            LeaderAction::NoteRename => {
1946                if let Some(path) = self.open_note_or_flash(tx) {
1947                    tx.send(AppEvent::FileOp(FileOp::ShowRename(path))).ok();
1948                }
1949            }
1950            LeaderAction::NoteMove => {
1951                if let Some(path) = self.open_note_or_flash(tx) {
1952                    tx.send(AppEvent::FileOp(FileOp::ShowMove(path))).ok();
1953                }
1954            }
1955            LeaderAction::NoteDelete => {
1956                if let Some(path) = self.open_note_or_flash(tx) {
1957                    tx.send(AppEvent::FileOp(FileOp::ShowDelete(path))).ok();
1958                }
1959            }
1960
1961            // +links
1962            LeaderAction::LinksTab(tab) => {
1963                self.open_drawer_view(DrawerView::Links, tx);
1964                self.panels.links_mut().show_tab(tab, tx);
1965            }
1966            LeaderAction::LinksGraph => {
1967                self.footer
1968                    .flash("local graph — coming soon".to_string(), tx);
1969            }
1970
1971            // +git/sync — status is live; the rest are display-only stubs
1972            // (spec §12 keeps git interactions out of scope).
1973            LeaderAction::GitStatus => {
1974                self.doc_meta.refresh_git(tx);
1975                let msg = self
1976                    .doc_meta
1977                    .git()
1978                    .cloned()
1979                    .unwrap_or_else(|| "not a git repository".to_string());
1980                self.footer.flash(msg, tx);
1981            }
1982            LeaderAction::GitSync | LeaderAction::GitLog | LeaderAction::GitDiff => {
1983                self.footer
1984                    .flash("git is display-only for now".to_string(), tx);
1985            }
1986
1987            // +vault
1988            LeaderAction::VaultSwitch => self.open_overlay(OverlayOpen::WorkspaceSwitcher, tx),
1989            LeaderAction::VaultReindex => {
1990                // Fast reindex right here — the same pipeline the Settings
1991                // screen runs, result surfaced as a footer flash.
1992                let vault = self.vault.clone();
1993                let tx2 = tx.clone();
1994                self.footer.flash("reindexing…".to_string(), tx);
1995                tokio::spawn(async move {
1996                    let started = std::time::Instant::now();
1997                    let result = vault.index_notes(kimun_core::NotesValidation::Fast).await;
1998                    let msg = match result {
1999                        Ok(_) => format!("reindexed in {:.1?}", started.elapsed()),
2000                        Err(e) => format!("reindex failed: {e}"),
2001                    };
2002                    tx2.send(AppEvent::FlashMessage(msg)).ok();
2003                });
2004            }
2005            // `v c` opens the config panel (the CFG drawer); `v t` opens the
2006            // theme picker directly (also reachable inside CFG via `t`).
2007            LeaderAction::VaultConfig => self.open_drawer_view(DrawerView::Config, tx),
2008            LeaderAction::VaultTheme => self.open_overlay(OverlayOpen::ThemePicker, tx),
2009            LeaderAction::VaultPreferences => {
2010                tx.send(AppEvent::OpenScreen(ScreenEvent::OpenPreferences))
2011                    .ok();
2012            }
2013            LeaderAction::AppOnboarding => {
2014                tx.send(AppEvent::OpenScreen(ScreenEvent::OpenOnboarding))
2015                    .ok();
2016            }
2017
2018            // +window
2019            LeaderAction::WindowZen => {
2020                self.panels.hide(PanelKind::Drawer);
2021                self.focus_editor();
2022            }
2023            LeaderAction::WindowSplit => {
2024                self.footer
2025                    .flash("editor splits — coming soon".to_string(), tx);
2026            }
2027            LeaderAction::WindowGrowDrawer => self.panels.adjust_drawer_width(4),
2028            LeaderAction::WindowShrinkDrawer => self.panels.adjust_drawer_width(-4),
2029
2030            // +this note
2031            LeaderAction::NoteToggleTodo => {
2032                self.footer
2033                    .flash("toggle todo — coming soon".to_string(), tx);
2034            }
2035            LeaderAction::NotePreview => {
2036                self.footer
2037                    .flash("preview — lands with phase 09".to_string(), tx);
2038            }
2039            LeaderAction::NoteCopyWikilink => {
2040                if let Some(path) = self.open_note_or_flash(tx) {
2041                    let link = format!("[[{}]]", path.get_clean_name());
2042                    crate::components::yank(link, "wikilink copied", tx);
2043                }
2044            }
2045            LeaderAction::NoteExport => {
2046                self.footer.flash("export — coming soon".to_string(), tx);
2047            }
2048            LeaderAction::NoteYankPath => {
2049                if let Some(path) = self.open_note_or_flash(tx) {
2050                    crate::components::yank(path.to_string(), "note path copied", tx);
2051                }
2052            }
2053
2054            // +text — markdown formatting, the only route to it. Gated on the
2055            // editor having focus for the same reason the shortcut tier gates
2056            // `ActionShortcuts::Text` on `editor_active`: the action reads the
2057            // editor's own selection, and applying it from a focused drawer
2058            // would edit a buffer the user is not looking at.
2059            LeaderAction::TextBold => self.apply_text_from_leader(TextAction::Bold, tx),
2060            LeaderAction::TextItalic => self.apply_text_from_leader(TextAction::Italic, tx),
2061            LeaderAction::TextStrikethrough => {
2062                self.apply_text_from_leader(TextAction::Strikethrough, tx)
2063            }
2064
2065            // +ask — the Ask workspace's conversation actions.
2066            LeaderAction::AskFocus => self.open_ask_workspace(tx),
2067            LeaderAction::AskNew => {
2068                self.with_ask_panel(tx, |panel, _tx| {
2069                    panel.thread_mut().clear();
2070                    panel.focus_composer();
2071                });
2072                self.panels.ask_sources_mut().reset(tx);
2073            }
2074            LeaderAction::AskCopy => {
2075                self.with_ask_panel(tx, |panel, tx| panel.copy_selected(tx));
2076            }
2077            LeaderAction::AskSave => {
2078                self.with_ask_panel(tx, |panel, tx| panel.save_selected(tx));
2079            }
2080            LeaderAction::AskRegenerate => {
2081                self.with_ask_panel(tx, |panel, tx| panel.regenerate_selected(tx));
2082                self.ask.sync_sources(&mut self.panels, tx);
2083            }
2084            LeaderAction::AskSource => {
2085                self.ensure_ask_workspace(tx);
2086                // The top source of the selected turn — sync the drawer to the
2087                // current turn first, then open its first source in the reader.
2088                self.ask.sync_sources_from_selected(&mut self.panels, tx);
2089                self.panels.ask_sources_mut().open_reader(0, tx);
2090                self.panels.focus(PanelKind::Drawer);
2091            }
2092
2093            LeaderAction::Palette => self.open_overlay(OverlayOpen::CommandPalette, tx),
2094            LeaderAction::Help => self.open_overlay(OverlayOpen::Cheatsheet, tx),
2095
2096            LeaderAction::NoteSave => {
2097                // Flush the periodic autosave immediately (no manual-save
2098                // concept; this force-persists the current buffer if dirty).
2099                self.spawn_autosave(tx);
2100            }
2101            LeaderAction::AppQuit => {
2102                tx.send(AppEvent::Quit).ok();
2103            }
2104
2105            // +pinned
2106            LeaderAction::FindPinned => self.open_overlay(OverlayOpen::PinnedNotes, tx),
2107            LeaderAction::NoteTogglePin => self.toggle_pin_open_note(tx),
2108            LeaderAction::PinnedJump(n) => self.jump_to_pinned(n, tx),
2109        }
2110    }
2111
2112    /// Kick off the async loads behind status line 2: the open note's
2113    /// backlink count and the workspace git summary. Results return as
2114    /// `BacklinkCountLoaded` / `GitStatusLoaded`; stale backlink completions
2115    /// are dropped by path. Contract: this runs when a note is *opened* (and
2116    /// the git half on autosave) — counts can go stale while a note stays
2117    /// open and another note adds a link to it; accepted for now.
2118    /// The one path that reveals FIND with a concrete query: used by saved
2119    /// searches and tag queries so they cannot drift on what "open FIND"
2120    /// means.
2121    fn open_find_with_query(&mut self, query: String, name: Option<String>, tx: &AppTx) {
2122        self.panels.open_drawer_view(DrawerView::Find);
2123        self.panels.query_mut().apply_query(query, name, tx.clone());
2124        self.panels.focus(PanelKind::Drawer);
2125    }
2126
2127    fn apply_saved_search(&mut self, query: String, name: String, tx: &AppTx) {
2128        // The virtual backlinks entry's name should not override the
2129        // default "Backlinks" title — but the panel's title logic already
2130        // shows "Backlinks" whenever the active query is `<{note}`, so it's
2131        // safe to always pass the name through.
2132        self.open_find_with_query(query, Some(name), tx);
2133    }
2134
2135    fn toggle_backlinks(&mut self, tx: &AppTx) {
2136        if self.drawer_open_on(DrawerView::Find) {
2137            self.panels.hide(PanelKind::Drawer);
2138        } else {
2139            self.open_drawer_view(DrawerView::Find, tx);
2140        }
2141    }
2142}
2143
2144#[async_trait]
2145impl AppScreen for EditorScreen {
2146    fn get_kind(&self) -> ScreenKind {
2147        ScreenKind::Editor
2148    }
2149
2150    async fn on_enter(&mut self, tx: &AppTx) {
2151        self.app_tx = Some(tx.clone());
2152        self.open_path(self.path.clone(), None, tx).await;
2153    }
2154
2155    fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
2156        // Classify first, mutate after: the pure classifier resolves the
2157        // event against the input precedence (see `editor_input`), and the
2158        // executor methods below apply the resulting intent.
2159        //
2160        // The click run is fed ahead of the snapshot — it is the one piece of
2161        // input-relevant state the event itself advances, and the classifier
2162        // cannot advance it and stay pure. Nothing user-visible moves here.
2163        let double_click = self.track_click(event, std::time::Instant::now());
2164        let ctx = self.input_ctx(double_click);
2165        // The settings lock guards the key bindings, which only the
2166        // shortcut tier reads — never lock for mouse/paste traffic (mouse
2167        // motion is high-frequency).
2168        let classification = if matches!(event, InputEvent::Key(_)) {
2169            let s = self.settings.read().unwrap();
2170            classify(event, &s.key_bindings, &ctx)
2171        } else {
2172            classify(event, &KeyBindings::empty(), &ctx)
2173        };
2174        self.apply_classification(classification, event, tx)
2175    }
2176
2177    fn render(&mut self, f: &mut ratatui::Frame) {
2178        let theme = &self.theme;
2179        f.render_widget(
2180            ratatui::widgets::Block::default().style(theme.base_style()),
2181            f.area(),
2182        );
2183
2184        let rows = Layout::default()
2185            .direction(Direction::Vertical)
2186            .constraints([
2187                Constraint::Length(1),
2188                Constraint::Min(0),
2189                Constraint::Length(crate::components::footer_bar::STATUS_BAR_HEIGHT),
2190            ])
2191            .split(f.area());
2192
2193        // ── Title bar (1 line): Kimün · note breadcrumb · workspace badge ──
2194        // Build the badge line once; its column width comes from the same
2195        // value that gets rendered, so glyph/separator tweaks can't drift.
2196        let workspace_badge = {
2197            let s = self.settings.read().unwrap();
2198            s.workspace_config.as_ref().map(|wc| {
2199                ratatui::text::Line::from(vec![
2200                    ratatui::text::Span::styled(
2201                        self.icons.workspace,
2202                        Style::default().fg(theme.accent.to_ratatui()),
2203                    ),
2204                    ratatui::text::Span::styled(
2205                        format!("  {}", wc.global.current_workspace),
2206                        Style::default().fg(theme.gray.to_ratatui()),
2207                    ),
2208                ])
2209            })
2210        };
2211        let workspace_label_width = workspace_badge
2212            .as_ref()
2213            .map(ratatui::text::Line::width)
2214            .unwrap_or_default();
2215        let breadcrumb = self
2216            .path
2217            .to_string()
2218            .trim_start_matches('/')
2219            .replace('/', " / ");
2220        let title_cols = Layout::default()
2221            .direction(Direction::Horizontal)
2222            .constraints([
2223                Constraint::Min(0),
2224                Constraint::Length(workspace_label_width as u16 + 2),
2225            ])
2226            .split(rows[0]);
2227        f.render_widget(
2228            Paragraph::new(ratatui::text::Line::from(vec![
2229                ratatui::text::Span::styled(
2230                    " Kimün ",
2231                    Style::default()
2232                        .fg(theme.accent.to_ratatui())
2233                        .add_modifier(ratatui::style::Modifier::BOLD),
2234                ),
2235                ratatui::text::Span::styled(
2236                    format!("─  {breadcrumb}"),
2237                    Style::default().fg(theme.fg_secondary.to_ratatui()),
2238                ),
2239            ])),
2240            title_cols[0],
2241        );
2242        if let Some(badge) = workspace_badge {
2243            f.render_widget(
2244                Paragraph::new(badge).alignment(ratatui::layout::Alignment::Right),
2245                title_cols[1],
2246            );
2247        }
2248
2249        // The panels lay themselves out and render. No panel shows its
2250        // focused highlight while an overlay sits over them.
2251        self.panels
2252            .render(f, rows[1], theme, !self.overlays.is_open());
2253
2254        // Status bar reflects the overlay if one is open, otherwise the
2255        // focused panel. `editing` drives the ⌨/≣ focus-context indicator —
2256        // true when a text field holds the cursor.
2257        let (focus_label, hints) = if let Some(kind) = self.overlays.active_kind() {
2258            (kind.label(), self.overlays.hint_shortcuts())
2259        } else {
2260            (self.panels.focused_label(), self.panels.focused_hints())
2261        };
2262        let editing = if let Some(kind) = self.overlays.active_kind() {
2263            // Browsers and the saved-searches modal host a query input;
2264            // dialogs are button/list selections.
2265            !matches!(kind, OverlayKind::Dialog)
2266        } else {
2267            match self.panels.focused() {
2268                PanelKind::Editor => true,
2269                PanelKind::Drawer => self.panels.drawer_is_text_input(),
2270                PanelKind::Rail => false,
2271            }
2272        };
2273        let path_str = self.path.to_string();
2274        // Link-under-cursor affordance (spec §5.2): `→ target · N backlinks`.
2275        // The backlink count loads async, cached per target.
2276        let link_segment = if self.panels.focused() == PanelKind::Editor && !self.overlays.is_open()
2277        {
2278            let link = self
2279                .panels
2280                .editor()
2281                .and_then(|e| e.follow_target_at_cursor());
2282            self.doc_meta
2283                .link_segment(link.as_ref(), &self.path, self.app_tx.as_ref())
2284        } else {
2285            None
2286        };
2287        // ln/col only when the editor buffer holds the cursor (not the
2288        // attachment view, which has none).
2289        let ln_col = (self.panels.focused() == PanelKind::Editor && !self.overlays.is_open())
2290            .then(|| {
2291                self.panels.editor().map(|e| {
2292                    let (row, col) = e.cursor_pos();
2293                    (row + 1, col + 1)
2294                })
2295            })
2296            .flatten();
2297        // Match count when the FIND drawer is the focused query context.
2298        let matches = (self.panels.focused() == PanelKind::Drawer
2299            && self.panels.active_drawer_view() == DrawerView::Find)
2300            .then(|| self.panels.query().result_count());
2301        let (global_hints, leader_timeout, gateway_label) = {
2302            let s = self.settings.read().unwrap();
2303            (
2304                crate::components::hints::global_hints(&s.key_bindings),
2305                std::time::Duration::from_millis(s.leader_timeout_ms),
2306                s.key_bindings
2307                    .first_combo_for(&ActionShortcuts::Leader)
2308                    .unwrap_or_else(|| "leader".to_string()),
2309            )
2310        };
2311        let ctx = crate::components::footer_bar::StatusContext {
2312            focus_label,
2313            editing,
2314            hints: &hints,
2315            global_hints: &global_hints,
2316            doc: crate::components::footer_bar::DocState {
2317                path: &path_str,
2318                dirty: self.panels.editor().is_some_and(|e| e.is_dirty()),
2319                ln_col,
2320                backlinks: self.doc_meta.backlinks(),
2321                git: self.doc_meta.git().cloned(),
2322                matches,
2323                link: link_segment,
2324                update: self
2325                    .update
2326                    .as_ref()
2327                    .map(|u| format!("⬆ {} available", u.latest)),
2328                rag: self.rag_status.label().map(|s| s.to_string()),
2329            },
2330        };
2331        self.footer.render(f, rows[2], theme, &ctx);
2332
2333        // which-key overlay — docked above the status bar once the user
2334        // hesitates mid-sequence (spec §8b).
2335        let whichkey_visible = self
2336            .leader
2337            .pending_since()
2338            .is_some_and(|since| since.elapsed() >= leader_timeout);
2339        if whichkey_visible {
2340            let gateway = gateway_label;
2341            let area = f.area();
2342            let h = crate::components::which_key::desired_height(&self.leader, area.width)
2343                .min(rows[1].height);
2344            let rect =
2345                ratatui::layout::Rect::new(area.x, rows[2].y.saturating_sub(h), area.width, h);
2346            crate::components::which_key::render(f, rect, theme, &self.leader, &gateway);
2347        }
2348
2349        // Overlay — rendered last so it appears on top of everything.
2350        self.overlays.render(f, f.area(), &self.theme);
2351    }
2352
2353    async fn handle_app_message(&mut self, msg: AppEvent, tx: &AppTx) {
2354        match msg {
2355            // Overlay data belongs to the open overlay alone (see CONTEXT.md):
2356            // route it there and stop. `NotConsumed` — no overlay open, or not
2357            // the kind the data was addressed to — means the result is stale;
2358            // it is dropped here and nothing else ever sees it.
2359            AppEvent::OverlayData(data) => {
2360                self.overlays.handle_data(&data, &self.vault, tx);
2361            }
2362            AppEvent::Update(flow) => self.handle_update(flow, tx),
2363            AppEvent::FileOp(op) => self.handle_file_op(op, tx).await,
2364            AppEvent::SavedSearch(flow) => self.handle_saved_search(flow, tx),
2365            msg => {
2366                // Async status results (backlink count, git, link meta) are
2367                // DocMeta's; everything else reaches the owned match.
2368                let Some(msg) = self.doc_meta.handle(msg, &self.path) else {
2369                    return;
2370                };
2371                self.handle_owned_message(msg, tx).await;
2372            }
2373        }
2374    }
2375
2376    /// The editor handles every path itself: notes open in the buffer,
2377    /// directories navigate the sidebar. Always consumes.
2378    async fn try_open_path(
2379        &mut self,
2380        path: VaultPath,
2381        emphasis: Option<Vec<String>>,
2382        tx: &AppTx,
2383    ) -> Option<VaultPath> {
2384        self.dismiss_overlay();
2385        if path.is_note() {
2386            self.open_path(path, emphasis, tx).await;
2387            self.focus_editor();
2388        } else {
2389            self.navigate_sidebar(path, tx);
2390        }
2391        None
2392    }
2393
2394    async fn try_open_attachment(&mut self, path: VaultPath, tx: &AppTx) -> Option<VaultPath> {
2395        self.dismiss_overlay();
2396        self.open_attachment(path, tx).await;
2397        None
2398    }
2399
2400    async fn on_exit(&mut self, _tx: &AppTx) {
2401        self.try_save().await;
2402    }
2403}
2404
2405#[cfg(test)]
2406mod tests {
2407    use super::*;
2408    use crate::keys::key_event_to_combo;
2409
2410    /// Compile-time test: `PanelKind` and `OverlayKind` are usable here.
2411    #[test]
2412    fn panel_kind_labels_and_overlay_kind_compile() {
2413        assert_eq!(PanelKind::Editor.label(), "EDITOR");
2414        assert_eq!(PanelKind::Rail.label(), "RAIL");
2415        assert_eq!(DrawerView::Files.label(), "FILES");
2416        assert_eq!(DrawerView::Find.label(), "FIND");
2417        let _kind = OverlayKind::Dialog;
2418    }
2419
2420    /// One screen over a fresh temp vault. Returns the `TempDir` so the vault
2421    /// directory outlives the test body.
2422    async fn test_screen() -> (
2423        EditorScreen,
2424        Arc<NoteVault>,
2425        SharedSettings,
2426        tempfile::TempDir,
2427    ) {
2428        use crate::settings::AppSettings;
2429        use kimun_core::VaultConfig;
2430        use std::sync::RwLock;
2431
2432        let dir = tempfile::TempDir::new().unwrap();
2433        let vault = Arc::new(
2434            NoteVault::new(VaultConfig::new(crate::test_support::sys(dir.path())))
2435                .await
2436                .unwrap(),
2437        );
2438        let settings: SharedSettings = Arc::new(RwLock::new(AppSettings::default()));
2439        let screen = EditorScreen::new(vault.clone(), VaultPath::root(), settings.clone());
2440        (screen, vault, settings, dir)
2441    }
2442
2443    fn key_event(code: ratatui::crossterm::event::KeyCode) -> InputEvent {
2444        use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
2445        InputEvent::Key(KeyEvent::new(code, KeyModifiers::NONE))
2446    }
2447
2448    fn ctrl_key(c: char) -> InputEvent {
2449        use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2450        InputEvent::Key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL))
2451    }
2452
2453    fn chr(c: char) -> InputEvent {
2454        key_event(ratatui::crossterm::event::KeyCode::Char(c))
2455    }
2456
2457    /// Ctrl-G (leader) then `o` `f` opens the FILES drawer — the full
2458    /// sequence fires with no menu drawn and no timeout wait.
2459    #[tokio::test]
2460    async fn leader_sequence_opens_drawer_view() {
2461        let (mut screen, _, _, _dir) = test_screen().await;
2462        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2463
2464        // Start from a non-Files view so the switch is observable.
2465        screen.panels.open_drawer_view(DrawerView::Tags);
2466
2467        screen.handle_input(&ctrl_key('g'), &tx);
2468        assert!(screen.leader.is_pending());
2469        screen.handle_input(&chr('o'), &tx);
2470        screen.handle_input(&chr('f'), &tx);
2471
2472        assert!(!screen.leader.is_pending());
2473        assert_eq!(screen.panels.active_drawer_view(), DrawerView::Files);
2474        assert_eq!(screen.panels.focused(), PanelKind::Drawer);
2475    }
2476
2477    /// `<leader> t b` while the find bar holds input must not touch the note.
2478    /// The chord tier already loses `ApplyText` to a claim (`apply_claim`);
2479    /// the leader route is the only other way to formatting and has to obey
2480    /// the same rule, or "Bold while typing a find pattern" comes back.
2481    #[tokio::test]
2482    async fn leader_formatting_yields_to_the_find_bar() {
2483        let (mut screen, _, _, _dir) = test_screen().await;
2484        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2485        screen
2486            .panels
2487            .editor_mut()
2488            .unwrap()
2489            .set_text("plain".to_string());
2490        screen.run_op(EditorOp::FindInBuffer, &tx);
2491        assert_eq!(
2492            screen.panels.editor().unwrap().claim(),
2493            crate::components::text_editor::EditorClaim::FindBar
2494        );
2495
2496        screen.handle_input(&ctrl_key('g'), &tx);
2497        screen.handle_input(&chr('t'), &tx);
2498        screen.handle_input(&chr('b'), &tx);
2499
2500        assert!(!screen.leader.is_pending());
2501        let text = screen.panels.editor().unwrap().get_text();
2502        assert!(
2503            !text.contains("**"),
2504            "the note was formatted under a claim: {text:?}"
2505        );
2506    }
2507
2508    /// The positive case, so the test above proves the guard and not a broken
2509    /// leader route: with no claim, `<leader> t b` inserts the bold pair.
2510    #[tokio::test]
2511    async fn leader_formatting_applies_with_the_editor_focused() {
2512        let (mut screen, _, _, _dir) = test_screen().await;
2513        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2514        screen
2515            .panels
2516            .editor_mut()
2517            .unwrap()
2518            .set_text("plain".to_string());
2519
2520        screen.handle_input(&ctrl_key('g'), &tx);
2521        screen.handle_input(&chr('t'), &tx);
2522        screen.handle_input(&chr('b'), &tx);
2523
2524        let text = screen.panels.editor().unwrap().get_text();
2525        assert!(text.contains("**"), "no bold marker was inserted: {text:?}");
2526    }
2527
2528    /// Opening the FILES drawer points the sidebar at the current note's
2529    /// directory, not the stale dir it was last left on.
2530    #[tokio::test]
2531    async fn opening_files_drawer_reveals_current_note_dir() {
2532        let (mut screen, _, _, _dir) = test_screen().await;
2533        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2534
2535        screen.path = VaultPath::new("projects").append(&VaultPath::note_path_from("plan"));
2536        // Sidebar left on an unrelated directory, drawer hidden.
2537        screen
2538            .panels
2539            .sidebar_mut()
2540            .navigate(VaultPath::new("other"), &tx);
2541        screen.panels.hide(PanelKind::Drawer);
2542
2543        screen.open_drawer_view(DrawerView::Files, &tx);
2544
2545        assert!(
2546            screen
2547                .panels
2548                .sidebar()
2549                .current_dir()
2550                .is_like(&VaultPath::new("projects"))
2551        );
2552    }
2553
2554    /// With FILES already open but browsed elsewhere, the open-file-browser
2555    /// shortcut is the "where is my note" gesture: it re-reveals the current
2556    /// note's directory.
2557    #[tokio::test]
2558    async fn file_browser_shortcut_rereveals_note_dir_when_already_open() {
2559        let (mut screen, _, _, _dir) = test_screen().await;
2560        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2561
2562        screen.path = VaultPath::new("projects").append(&VaultPath::note_path_from("plan"));
2563        screen.open_drawer_view(DrawerView::Files, &tx);
2564        // User browses away while FILES stays open.
2565        screen
2566            .panels
2567            .sidebar_mut()
2568            .navigate(VaultPath::new("other"), &tx);
2569
2570        screen.handle_input(&ctrl_key('e'), &tx);
2571
2572        assert!(
2573            screen
2574                .panels
2575                .sidebar()
2576                .current_dir()
2577                .is_like(&VaultPath::new("projects"))
2578        );
2579    }
2580
2581    /// The gateway works mid-typing: with the editor focused, Ctrl-G arms
2582    /// the sequence and the next chars are consumed, not inserted.
2583    #[tokio::test]
2584    async fn leader_consumes_keys_while_editor_focused() {
2585        let (mut screen, _, _, _dir) = test_screen().await;
2586        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2587
2588        screen.panels.editor_mut().unwrap().set_text(String::new());
2589        assert_eq!(screen.panels.focused(), PanelKind::Editor);
2590
2591        screen.handle_input(&ctrl_key('g'), &tx);
2592        assert!(screen.leader.is_pending());
2593        // 'w' is a group key; it must not land in the buffer.
2594        screen.handle_input(&chr('w'), &tx);
2595        screen.handle_input(&chr('z'), &tx); // zen: hides the drawer
2596        assert_eq!(screen.panels.editor().unwrap().get_text(), "");
2597        assert!(!screen.panels.is_visible(PanelKind::Drawer));
2598    }
2599
2600    /// Esc cancels a pending sequence and returns focus to the editor.
2601    #[tokio::test]
2602    async fn leader_esc_cancels_and_focuses_editor() {
2603        let (mut screen, _, _, _dir) = test_screen().await;
2604        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2605
2606        screen.panels.focus(PanelKind::Rail);
2607        screen.handle_input(&ctrl_key('g'), &tx);
2608        screen.handle_input(&chr('f'), &tx);
2609        screen.handle_input(&key_event(ratatui::crossterm::event::KeyCode::Esc), &tx);
2610
2611        assert!(!screen.leader.is_pending());
2612        assert_eq!(screen.panels.focused(), PanelKind::Editor);
2613    }
2614
2615    /// Drain `rx` until an event matching `pred` arrives, or time out.
2616    async fn wait_for(
2617        rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
2618        pred: impl Fn(&AppEvent) -> bool,
2619    ) -> AppEvent {
2620        tokio::time::timeout(std::time::Duration::from_secs(2), async {
2621            loop {
2622                let e = rx.recv().await.expect("channel open");
2623                if pred(&e) {
2624                    break e;
2625                }
2626            }
2627        })
2628        .await
2629        .expect("expected event")
2630    }
2631
2632    /// Leader `f p` opens the pinned-notes dialog (a plain overlay open, same
2633    /// door as every other leader-triggered dialog).
2634    #[tokio::test(flavor = "multi_thread")]
2635    async fn leader_f_p_opens_the_pinned_notes_dialog() {
2636        let (mut screen, _, _, _dir) = test_screen().await;
2637        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2638        screen.handle_input(&ctrl_key('g'), &tx);
2639        screen.handle_input(&chr('f'), &tx);
2640        screen.handle_input(&chr('p'), &tx);
2641        assert!(screen.overlays.is_open());
2642        assert_eq!(
2643            screen.overlays.active_kind(),
2644            Some(crate::components::overlay::OverlayKind::Dialog)
2645        );
2646    }
2647
2648    /// Leader `m i` pins the open note, flashing its 1-based position, then
2649    /// unpins it on the second press.
2650    #[tokio::test(flavor = "multi_thread")]
2651    async fn leader_m_i_toggles_the_pin_and_flashes() {
2652        let (mut screen, vault, _, _dir) = test_screen().await;
2653        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2654        let note = VaultPath::new("plan.md");
2655        vault.create_note(&note, "hi").await.unwrap();
2656        screen.path = note.clone();
2657
2658        screen.handle_input(&ctrl_key('g'), &tx);
2659        screen.handle_input(&chr('m'), &tx);
2660        screen.handle_input(&chr('i'), &tx);
2661        let e = wait_for(&mut rx, |e| matches!(e, AppEvent::FlashMessage(_))).await;
2662        assert!(
2663            matches!(&e, AppEvent::FlashMessage(m) if m == "pinned as 1 of 9"),
2664            "{e:?}"
2665        );
2666        // list_pinned_notes returns canonical, vault-absolute paths.
2667        assert_eq!(
2668            vault.list_pinned_notes().await.unwrap(),
2669            vec![VaultPath::new("/plan.md")]
2670        );
2671
2672        screen.handle_input(&ctrl_key('g'), &tx);
2673        screen.handle_input(&chr('m'), &tx);
2674        screen.handle_input(&chr('i'), &tx);
2675        let e = wait_for(&mut rx, |e| matches!(e, AppEvent::FlashMessage(_))).await;
2676        assert!(
2677            matches!(&e, AppEvent::FlashMessage(m) if m == "unpinned"),
2678            "{e:?}"
2679        );
2680        assert!(vault.list_pinned_notes().await.unwrap().is_empty());
2681    }
2682
2683    /// A tenth pin is refused with a message telling the user what to do —
2684    /// the cap is what guarantees every pinned note keeps a digit shortcut.
2685    #[tokio::test(flavor = "multi_thread")]
2686    async fn leader_m_i_refuses_a_tenth_pin() {
2687        let (mut screen, vault, _, _dir) = test_screen().await;
2688        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2689        for i in 0..kimun_core::PINNED_NOTES_CAP {
2690            let n = VaultPath::new(format!("n{i}.md"));
2691            vault.create_note(&n, "hi").await.unwrap();
2692            vault.toggle_pinned_note(&n).await.unwrap();
2693        }
2694        let extra = VaultPath::new("extra.md");
2695        vault.create_note(&extra, "hi").await.unwrap();
2696        screen.path = extra;
2697        screen.handle_input(&ctrl_key('g'), &tx);
2698        screen.handle_input(&chr('m'), &tx);
2699        screen.handle_input(&chr('i'), &tx);
2700        let e = wait_for(&mut rx, |e| matches!(e, AppEvent::FlashMessage(_))).await;
2701        assert!(
2702            matches!(&e, AppEvent::FlashMessage(m) if m == "already 9 pinned — unpin one first"),
2703            "{e:?}"
2704        );
2705    }
2706
2707    /// The open path can name a note that is no longer on disk — removed
2708    /// in another terminal, or a create dialog dismissed with the path left
2709    /// set. Pinning it would only store a pin born "missing", so it flashes.
2710    #[tokio::test(flavor = "multi_thread")]
2711    async fn leader_m_i_on_a_missing_note_flashes() {
2712        let (mut screen, vault, _, _dir) = test_screen().await;
2713        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2714        screen.path = VaultPath::new("gone.md");
2715        screen.handle_input(&ctrl_key('g'), &tx);
2716        screen.handle_input(&chr('m'), &tx);
2717        screen.handle_input(&chr('i'), &tx);
2718        let e = wait_for(&mut rx, |e| matches!(e, AppEvent::FlashMessage(_))).await;
2719        assert!(
2720            matches!(&e, AppEvent::FlashMessage(m) if m == "note not found: gone.md"),
2721            "{e:?}"
2722        );
2723        assert!(vault.list_pinned_notes().await.unwrap().is_empty());
2724    }
2725
2726    /// Every "+this note" action shares one definition of "this note": with
2727    /// the Ask workspace up, `m c` refuses exactly as `m i` does, instead of
2728    /// copying a wikilink to the note the user is no longer looking at.
2729    #[tokio::test(flavor = "multi_thread")]
2730    async fn leader_m_c_with_ask_shown_flashes_like_m_i() {
2731        let (mut screen, vault, _, _dir) = test_screen().await;
2732        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2733        let note = VaultPath::new("plan.md");
2734        vault.create_note(&note, "hi").await.unwrap();
2735        screen.path = note;
2736        screen.open_drawer_view(DrawerView::Ask, &tx);
2737
2738        screen.handle_input(&ctrl_key('g'), &tx);
2739        screen.handle_input(&chr('m'), &tx);
2740        screen.handle_input(&chr('c'), &tx);
2741        let e = wait_for(&mut rx, |e| matches!(e, AppEvent::FlashMessage(_))).await;
2742        assert!(
2743            matches!(&e, AppEvent::FlashMessage(m) if m == "no note open"),
2744            "{e:?}"
2745        );
2746    }
2747
2748    /// Toggling with no note open (the screen starts on the vault root, which
2749    /// is not a note) flashes rather than doing nothing.
2750    #[tokio::test(flavor = "multi_thread")]
2751    async fn leader_m_i_with_no_note_open_flashes() {
2752        let (mut screen, _, _, _dir) = test_screen().await;
2753        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2754        // test_screen opens on VaultPath::root(), which is not a note.
2755        screen.handle_input(&ctrl_key('g'), &tx);
2756        screen.handle_input(&chr('m'), &tx);
2757        screen.handle_input(&chr('i'), &tx);
2758        let e = wait_for(&mut rx, |e| matches!(e, AppEvent::FlashMessage(_))).await;
2759        assert!(
2760            matches!(&e, AppEvent::FlashMessage(m) if m == "no note open"),
2761            "{e:?}"
2762        );
2763    }
2764
2765    /// The attachment view counts as "no note open" even though `self.path`
2766    /// is set (to the attachment's path, itself never a note).
2767    #[tokio::test(flavor = "multi_thread")]
2768    async fn leader_m_i_with_attachment_shown_flashes() {
2769        let (mut screen, vault, _, _dir) = test_screen().await;
2770        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2771        vault
2772            .save_attachment(&VaultPath::new("assets/diagram.png"), &[1, 2, 3])
2773            .await
2774            .unwrap();
2775        screen
2776            .try_open_attachment(VaultPath::new("assets/diagram.png"), &tx)
2777            .await;
2778        assert!(screen.panels.is_showing_attachment());
2779
2780        screen.handle_input(&ctrl_key('g'), &tx);
2781        screen.handle_input(&chr('m'), &tx);
2782        screen.handle_input(&chr('i'), &tx);
2783        let e = wait_for(&mut rx, |e| matches!(e, AppEvent::FlashMessage(_))).await;
2784        assert!(
2785            matches!(&e, AppEvent::FlashMessage(m) if m == "no note open"),
2786            "{e:?}"
2787        );
2788    }
2789
2790    /// The Ask workspace counts as "no note open" even though `self.path`
2791    /// still names the note that was open before switching to Ask — the
2792    /// editor area is not showing it.
2793    #[tokio::test(flavor = "multi_thread")]
2794    async fn leader_m_i_with_ask_shown_flashes() {
2795        let (mut screen, vault, _, _dir) = test_screen().await;
2796        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2797        let note = VaultPath::new("plan.md");
2798        vault.create_note(&note, "hi").await.unwrap();
2799        screen.path = note;
2800        screen.open_drawer_view(DrawerView::Ask, &tx);
2801        assert!(screen.panels.is_showing_ask());
2802
2803        screen.handle_input(&ctrl_key('g'), &tx);
2804        screen.handle_input(&chr('m'), &tx);
2805        screen.handle_input(&chr('i'), &tx);
2806        let e = wait_for(&mut rx, |e| matches!(e, AppEvent::FlashMessage(_))).await;
2807        assert!(
2808            matches!(&e, AppEvent::FlashMessage(m) if m == "no note open"),
2809            "{e:?}"
2810        );
2811    }
2812
2813    /// Leader digit `2` opens the second pinned note (1-based).
2814    #[tokio::test(flavor = "multi_thread")]
2815    async fn leader_digit_opens_the_nth_pinned_note() {
2816        let (mut screen, vault, _, _dir) = test_screen().await;
2817        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2818        let a = VaultPath::new("a.md");
2819        let b = VaultPath::new("b.md");
2820        vault.create_note(&a, "a").await.unwrap();
2821        vault.create_note(&b, "b").await.unwrap();
2822        vault.toggle_pinned_note(&a).await.unwrap();
2823        vault.toggle_pinned_note(&b).await.unwrap();
2824
2825        screen.handle_input(&ctrl_key('g'), &tx);
2826        screen.handle_input(&chr('2'), &tx);
2827        let e = wait_for(&mut rx, |e| matches!(e, AppEvent::OpenPath { .. })).await;
2828        // list_pinned_notes returns the canonical, vault-absolute form
2829        // (`/b.md`); is_like compares components only.
2830        assert!(
2831            matches!(&e, AppEvent::OpenPath { path, .. } if path.is_like(&b)),
2832            "{e:?}"
2833        );
2834    }
2835
2836    /// A digit with no pin in that slot flashes instead of failing silently.
2837    #[tokio::test(flavor = "multi_thread")]
2838    async fn leader_digit_with_no_such_pin_flashes() {
2839        let (mut screen, _, _, _dir) = test_screen().await;
2840        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2841        screen.handle_input(&ctrl_key('g'), &tx);
2842        screen.handle_input(&chr('4'), &tx);
2843        let e = wait_for(&mut rx, |e| matches!(e, AppEvent::FlashMessage(_))).await;
2844        assert!(
2845            matches!(&e, AppEvent::FlashMessage(m) if m == "no pinned note 4"),
2846            "{e:?}"
2847        );
2848    }
2849
2850    /// A pin whose note has vanished from disk flashes — and is kept, never
2851    /// pruned, since only an explicit unpin removes it.
2852    #[tokio::test(flavor = "multi_thread")]
2853    async fn leader_digit_to_a_missing_pin_flashes() {
2854        let (mut screen, vault, _, dir) = test_screen().await;
2855        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2856        let gone = VaultPath::new("gone.md");
2857        vault.create_note(&gone, "hi").await.unwrap();
2858        vault.toggle_pinned_note(&gone).await.unwrap();
2859        // Vanishes outside kimün after being pinned.
2860        std::fs::remove_file(dir.path().join("gone.md")).unwrap();
2861        screen.handle_input(&ctrl_key('g'), &tx);
2862        screen.handle_input(&chr('1'), &tx);
2863        let e = wait_for(&mut rx, |e| matches!(e, AppEvent::FlashMessage(_))).await;
2864        // The flashed path is the canonical form list_pinned_notes returned.
2865        assert!(
2866            matches!(&e, AppEvent::FlashMessage(m) if m == "pinned note not found: /gone.md"),
2867            "{e:?}"
2868        );
2869    }
2870
2871    /// Bare Space never leads — the leader is only the configured gateway.
2872    /// Space types a space in the editor and never arms the sequence, whatever
2873    /// panel is focused (rail, a list drawer, or a text-input drawer).
2874    #[tokio::test]
2875    async fn space_never_leads() {
2876        let (mut screen, _, _, _dir) = test_screen().await;
2877        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2878
2879        // Editor focused: Space must insert a space.
2880        screen.panels.editor_mut().unwrap().set_text(String::new());
2881        screen.handle_input(&chr(' '), &tx);
2882        assert!(!screen.leader.is_pending());
2883        assert_eq!(screen.panels.editor().unwrap().get_text(), " ");
2884
2885        // Rail focused: Space must NOT lead.
2886        screen.panels.focus(PanelKind::Rail);
2887        screen.handle_input(&chr(' '), &tx);
2888        assert!(!screen.leader.is_pending());
2889
2890        // FIND drawer (a text input): Space must NOT lead.
2891        screen.panels.open_drawer_view(DrawerView::Find);
2892        screen.panels.focus(PanelKind::Drawer);
2893        screen.handle_input(&chr(' '), &tx);
2894        assert!(!screen.leader.is_pending());
2895    }
2896
2897    #[tokio::test]
2898    async fn persist_saved_search_writes_via_core() {
2899        let (screen, _, _, _dir) = test_screen().await;
2900
2901        screen.persist_saved_search("t", "#todo").await.unwrap();
2902
2903        let all = screen.vault.list_saved_searches().await.unwrap();
2904        assert!(all.iter().any(|s| s.name == "t" && s.query == "#todo"));
2905    }
2906
2907    #[tokio::test]
2908    async fn applying_saved_search_sets_panel_query_and_focuses_it() {
2909        let (mut screen, _, _, _dir) = test_screen().await;
2910
2911        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2912        screen.apply_saved_search(
2913            "<{note}".to_string(),
2914            "Backlinks (current note)".to_string(),
2915            &tx,
2916        );
2917        assert!(screen.panels.is_visible(PanelKind::Drawer));
2918        assert_eq!(screen.panels.active_drawer_view(), DrawerView::Find);
2919        assert_eq!(screen.panels.query().active_query(), "<{note}");
2920        assert_eq!(screen.panels.focused(), PanelKind::Drawer);
2921    }
2922
2923    #[tokio::test]
2924    async fn saved_search_persisted_repins_panel_breadcrumb() {
2925        let (mut screen, _, _, _dir) = test_screen().await;
2926
2927        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2928        screen.apply_saved_search("#todo".to_string(), "todo".to_string(), &tx);
2929        screen
2930            .panels
2931            .query_mut()
2932            .set_active_query("#todo and #urgent".to_string());
2933
2934        screen
2935            .handle_app_message(
2936                AppEvent::SavedSearch(SavedSearchFlow::Persisted {
2937                    name: "urgent-todos".to_string(),
2938                    query: "#todo and #urgent".to_string(),
2939                    source: SaveSource::QueryPanel,
2940                }),
2941                &tx,
2942            )
2943            .await;
2944
2945        assert_eq!(
2946            screen.panels.query().saved_search_breadcrumb().as_deref(),
2947            Some("urgent-todos"),
2948            "a persisted panel-sourced save re-pins the breadcrumb"
2949        );
2950    }
2951
2952    #[tokio::test]
2953    async fn persisted_note_browser_save_does_not_repin_even_on_equal_query() {
2954        let (mut screen, _, _, _dir) = test_screen().await;
2955
2956        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2957        screen.apply_saved_search("#todo".to_string(), "todo".to_string(), &tx);
2958
2959        // A note-browser-sourced save whose query text happens to equal the
2960        // panel's live query: source identity, not text equality, decides.
2961        screen
2962            .handle_app_message(
2963                AppEvent::SavedSearch(SavedSearchFlow::Persisted {
2964                    name: "inbox".to_string(),
2965                    query: "#todo".to_string(),
2966                    source: SaveSource::NoteBrowser,
2967                }),
2968                &tx,
2969            )
2970            .await;
2971
2972        assert_eq!(
2973            screen.panels.query().saved_search_breadcrumb().as_deref(),
2974            Some("todo"),
2975            "a note-browser save must not steal the panel's provenance"
2976        );
2977    }
2978
2979    #[tokio::test]
2980    async fn persisted_query_as_name_save_skips_repin() {
2981        let (mut screen, _, _, _dir) = test_screen().await;
2982
2983        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2984        screen.apply_saved_search("#todo".to_string(), "todo".to_string(), &tx);
2985
2986        // The empty-name fallback saved the query under its own text. Pinning
2987        // that as the breadcrumb name would just echo the query (CONTEXT.md:
2988        // the breadcrumb is a distinct provenance tag, not the query).
2989        screen
2990            .handle_app_message(
2991                AppEvent::SavedSearch(SavedSearchFlow::Persisted {
2992                    name: "#todo".to_string(),
2993                    query: "#todo".to_string(),
2994                    source: SaveSource::QueryPanel,
2995                }),
2996                &tx,
2997            )
2998            .await;
2999
3000        assert_eq!(
3001            screen.panels.query().saved_search_breadcrumb().as_deref(),
3002            Some("todo"),
3003            "a query-as-name save leaves the breadcrumb alone"
3004        );
3005    }
3006
3007    #[tokio::test]
3008    async fn save_search_confirmed_persists_then_emits_persisted() {
3009        let (mut screen, vault, _, _dir) = test_screen().await;
3010
3011        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3012        screen
3013            .handle_app_message(
3014                AppEvent::SavedSearch(SavedSearchFlow::Confirmed {
3015                    name: "mine".to_string(),
3016                    query: "#todo".to_string(),
3017                    source: SaveSource::QueryPanel,
3018                }),
3019                &tx,
3020            )
3021            .await;
3022
3023        // The write runs in a spawned task; Persisted arrives only on success.
3024        let event = tokio::time::timeout(std::time::Duration::from_secs(5), async {
3025            loop {
3026                match rx.recv().await {
3027                    Some(e @ AppEvent::SavedSearch(SavedSearchFlow::Persisted { .. })) => break e,
3028                    Some(_) => continue,
3029                    None => panic!("channel closed before SavedSearchPersisted"),
3030                }
3031            }
3032        })
3033        .await
3034        .expect("SavedSearchPersisted within timeout");
3035
3036        let AppEvent::SavedSearch(SavedSearchFlow::Persisted {
3037            name,
3038            query,
3039            source,
3040        }) = event
3041        else {
3042            unreachable!()
3043        };
3044        assert_eq!((name.as_str(), query.as_str()), ("mine", "#todo"));
3045        assert_eq!(source, SaveSource::QueryPanel);
3046        let all = vault.list_saved_searches().await.unwrap();
3047        assert!(all.iter().any(|s| s.name == "mine" && s.query == "#todo"));
3048    }
3049
3050    #[tokio::test]
3051    async fn save_query_source_carries_panel_provenance() {
3052        let (mut screen, _, _, _dir) = test_screen().await;
3053
3054        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3055        screen.apply_saved_search("#todo".to_string(), "todo".to_string(), &tx);
3056
3057        assert_eq!(
3058            screen.save_query_source(),
3059            Some((
3060                "#todo".to_string(),
3061                Some("todo".to_string()),
3062                SaveSource::QueryPanel
3063            )),
3064            "the save dialog opens pre-filled with the breadcrumb provenance"
3065        );
3066    }
3067
3068    // The try_save timeout-abort regression tests (commits 55eb49ed +
3069    // 5e28b796) previously lived here against `await_or_abort`. The
3070    // logic now lives in `SingleSlotTask::await_with_timeout` and is
3071    // covered by `single_slot_task_timeout_returns_none_keeps_handle`
3072    // in `crate::util::single_slot_task`.
3073
3074    #[tokio::test]
3075    async fn saved_search_selected_then_close_overlay_keeps_backlinks_focus() {
3076        use crate::settings::AppSettings;
3077        use kimun_core::VaultConfig;
3078        use std::sync::RwLock;
3079
3080        let dir = tempfile::TempDir::new().unwrap();
3081        let vault = Arc::new(
3082            NoteVault::new(VaultConfig::new(crate::test_support::sys(dir.path())))
3083                .await
3084                .unwrap(),
3085        );
3086        let settings: SharedSettings = Arc::new(RwLock::new(AppSettings::default()));
3087        let mut screen = EditorScreen::new(vault, VaultPath::root(), settings);
3088
3089        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3090        // Replay the exact sequence the saved-searches modal emits on select.
3091        screen
3092            .handle_app_message(
3093                AppEvent::SavedSearch(SavedSearchFlow::Selected {
3094                    query: "<{note}".to_string(),
3095                    name: "Backlinks (current note)".to_string(),
3096                }),
3097                &tx,
3098            )
3099            .await;
3100        screen.handle_app_message(AppEvent::CloseOverlay, &tx).await;
3101
3102        assert!(
3103            screen.panels.focused() == PanelKind::Drawer
3104                && screen.panels.active_drawer_view() == DrawerView::Find,
3105            "focus should remain on the FIND drawer after select + close"
3106        );
3107        assert!(!screen.overlays.is_open(), "overlay should be closed");
3108    }
3109
3110    /// Capture-all guard: while an overlay is open, an opener action
3111    /// (QuickNote) must NOT replace it. Drives a real QuickNote keypress
3112    /// through `handle_input` so the guard in the action arm is exercised
3113    /// end-to-end.
3114    #[tokio::test]
3115    async fn opener_action_does_not_replace_open_overlay() {
3116        use crate::settings::AppSettings;
3117        use kimun_core::VaultConfig;
3118        use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
3119        use std::sync::RwLock;
3120
3121        let dir = tempfile::TempDir::new().unwrap();
3122        let vault = Arc::new(
3123            NoteVault::new(VaultConfig::new(crate::test_support::sys(dir.path())))
3124                .await
3125                .unwrap(),
3126        );
3127        let settings: SharedSettings = Arc::new(RwLock::new(AppSettings::default()));
3128        let mut screen = EditorScreen::new(vault.clone(), VaultPath::root(), settings.clone());
3129
3130        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3131
3132        // Force a SavedSearches overlay open and focus it, as if the user had
3133        // opened it via its action.
3134        {
3135            let s = settings.read().unwrap();
3136            let modal = SavedSearchesModal::new(
3137                vault.clone(),
3138                s.key_bindings.clone(),
3139                s.icons(),
3140                tx.clone(),
3141            );
3142            drop(s);
3143            screen.overlays.open(Box::new(modal), screen.opener_focus());
3144        }
3145        assert_eq!(
3146            screen.overlays.active_kind(),
3147            Some(OverlayKind::SavedSearches),
3148            "precondition: SavedSearches overlay is active"
3149        );
3150
3151        // The QuickNote action key (Ctrl+W by default). Assert the binding
3152        // resolves to QuickNote so this test fails loudly if the default
3153        // rebinds, rather than silently exercising the wrong path.
3154        let quick_note_event = KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL);
3155        {
3156            let s = settings.read().unwrap();
3157            let combo = key_event_to_combo(&quick_note_event).expect("Ctrl+W maps to a combo");
3158            assert_eq!(
3159                s.key_bindings.get_action(&combo),
3160                Some(ActionShortcuts::QuickNote),
3161                "test assumes Ctrl+W is bound to QuickNote"
3162            );
3163        }
3164
3165        screen.handle_input(&InputEvent::Key(quick_note_event), &tx);
3166
3167        // The guard must have suppressed the open: the SavedSearches overlay
3168        // is still active, NOT replaced by the QuickNote dialog.
3169        assert_eq!(
3170            screen.overlays.active_kind(),
3171            Some(OverlayKind::SavedSearches),
3172            "open overlay must not be replaced by a QuickNote opener action"
3173        );
3174    }
3175
3176    /// Focus actions are inert while an overlay owns input: pressing
3177    /// FocusEditor (Ctrl+L) with a dialog open must not reveal or move the
3178    /// panels underneath, so closing the dialog leaves the layout unchanged.
3179    #[tokio::test]
3180    async fn focus_action_is_noop_while_overlay_open() {
3181        use crate::settings::AppSettings;
3182        use kimun_core::VaultConfig;
3183        use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
3184        use std::sync::RwLock;
3185
3186        let dir = tempfile::TempDir::new().unwrap();
3187        let vault = Arc::new(
3188            NoteVault::new(VaultConfig::new(crate::test_support::sys(dir.path())))
3189                .await
3190                .unwrap(),
3191        );
3192        let settings: SharedSettings = Arc::new(RwLock::new(AppSettings::default()));
3193        let mut screen = EditorScreen::new(vault.clone(), VaultPath::root(), settings.clone());
3194
3195        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3196
3197        // Open a SavedSearches overlay (Query panel starts hidden).
3198        {
3199            let s = settings.read().unwrap();
3200            let modal = SavedSearchesModal::new(
3201                vault.clone(),
3202                s.key_bindings.clone(),
3203                s.icons(),
3204                tx.clone(),
3205            );
3206            drop(s);
3207            screen.overlays.open(Box::new(modal), screen.opener_focus());
3208        }
3209        assert_ne!(screen.panels.active_drawer_view(), DrawerView::Find);
3210        let focused_before = screen.panels.focused();
3211
3212        // Ctrl+L (FocusEditor / focus right) must be consumed but do nothing.
3213        let focus_right = KeyEvent::new(KeyCode::Char('l'), KeyModifiers::CONTROL);
3214        {
3215            let s = settings.read().unwrap();
3216            let combo = key_event_to_combo(&focus_right).expect("Ctrl+L maps to a combo");
3217            assert_eq!(
3218                s.key_bindings.get_action(&combo),
3219                Some(ActionShortcuts::FocusEditor),
3220                "test assumes Ctrl+L is bound to FocusEditor"
3221            );
3222        }
3223        screen.handle_input(&InputEvent::Key(focus_right), &tx);
3224
3225        assert_eq!(
3226            screen.panels.focused(),
3227            focused_before,
3228            "focus action must not move focus while an overlay is open"
3229        );
3230        assert_ne!(
3231            screen.panels.active_drawer_view(),
3232            DrawerView::Find,
3233            "focus action must not switch the drawer view while an overlay is open"
3234        );
3235        assert_eq!(
3236            screen.overlays.active_kind(),
3237            Some(OverlayKind::SavedSearches),
3238            "overlay stays active"
3239        );
3240    }
3241
3242    /// Opening the journal while an overlay is up dismisses the overlay, so the
3243    /// journal note isn't loaded behind it. OpenJournal is now resolved at the
3244    /// app level into an OpenPath, which lands here in `try_open_path` — that's
3245    /// the door that must dismiss the overlay.
3246    #[tokio::test(flavor = "multi_thread")]
3247    async fn open_journal_dismisses_open_overlay() {
3248        let vault = crate::test_support::temp_vault("editor-journal").await;
3249        vault.validate_and_init().await.unwrap();
3250        let settings = std::sync::Arc::new(std::sync::RwLock::new(
3251            crate::settings::AppSettings::default(),
3252        ));
3253        let mut screen = EditorScreen::new(vault.clone(), VaultPath::root(), settings.clone());
3254        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3255
3256        {
3257            let s = settings.read().unwrap();
3258            let modal = SavedSearchesModal::new(
3259                vault.clone(),
3260                s.key_bindings.clone(),
3261                s.icons(),
3262                tx.clone(),
3263            );
3264            drop(s);
3265            screen.present_overlay(Box::new(modal));
3266        }
3267        assert!(screen.overlays.is_open(), "precondition: overlay open");
3268
3269        let (details, _, _) = vault.journal_entry().await.unwrap();
3270        screen.try_open_path(details.path, None, &tx).await;
3271
3272        assert!(
3273            !screen.overlays.is_open(),
3274            "opening the journal must dismiss the overlay before loading the note"
3275        );
3276    }
3277
3278    /// Opening an attachment swaps the editor area to the read-only attachment
3279    /// view: the note editor accessor reports absent, and FollowLink in that
3280    /// state opens externally rather than touching the (absent) editor.
3281    #[tokio::test(flavor = "multi_thread")]
3282    async fn opening_attachment_shows_attachment_view() {
3283        let vault = crate::test_support::temp_vault("editor-attachment").await;
3284        vault.validate_and_init().await.unwrap();
3285        vault
3286            .save_attachment(&VaultPath::new("assets/diagram.png"), &[1, 2, 3])
3287            .await
3288            .unwrap();
3289        let settings = std::sync::Arc::new(std::sync::RwLock::new(
3290            crate::settings::AppSettings::default(),
3291        ));
3292        let mut screen = EditorScreen::new(vault.clone(), VaultPath::root(), settings);
3293        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3294
3295        // Precondition: a note editor is mounted.
3296        assert!(screen.panels.editor().is_some());
3297
3298        screen
3299            .try_open_attachment(VaultPath::new("assets/diagram.png"), &tx)
3300            .await;
3301
3302        assert!(
3303            screen.panels.is_showing_attachment(),
3304            "the editor area shows the attachment view"
3305        );
3306        assert!(
3307            screen.panels.editor().is_none(),
3308            "no note editor is mounted while an attachment is shown"
3309        );
3310        assert_eq!(
3311            screen.panels.attachment_path(),
3312            Some(&VaultPath::new("assets/diagram.png"))
3313        );
3314
3315        // Returning to a note swaps the editor area back.
3316        vault
3317            .create_note(&VaultPath::new("note.md"), "hi")
3318            .await
3319            .unwrap();
3320        screen.open_path(VaultPath::new("note.md"), None, &tx).await;
3321        assert!(!screen.panels.is_showing_attachment());
3322        assert!(screen.panels.editor().is_some());
3323    }
3324
3325    #[tokio::test]
3326    async fn save_query_from_note_browser_opens_save_dialog() {
3327        use crate::settings::AppSettings;
3328        use kimun_core::VaultConfig;
3329        use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
3330        use std::sync::RwLock;
3331
3332        let dir = tempfile::TempDir::new().unwrap();
3333        let vault = Arc::new(
3334            NoteVault::new(VaultConfig::new(crate::test_support::sys(dir.path())))
3335                .await
3336                .unwrap(),
3337        );
3338        let settings: SharedSettings = Arc::new(RwLock::new(AppSettings::default()));
3339        let mut screen = EditorScreen::new(vault.clone(), VaultPath::root(), settings.clone());
3340
3341        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3342
3343        // Open a note browser carrying a query, as if the user typed "#todo".
3344        {
3345            let s = settings.read().unwrap();
3346            let provider = resolving_search_source(vault.clone(), s.current_last_paths(), None);
3347            let modal = NoteBrowserModal::with_initial_query(
3348                "Note Browser",
3349                BrowserScope::Query,
3350                provider,
3351                vault.clone(),
3352                s.key_bindings.clone(),
3353                s.icons(),
3354                tx.clone(),
3355                "#todo",
3356            );
3357            drop(s);
3358            screen.overlays.open(Box::new(modal), screen.opener_focus());
3359        }
3360        assert_eq!(
3361            screen.overlays.active_kind(),
3362            Some(OverlayKind::NoteBrowser),
3363            "precondition: note browser is active with a query"
3364        );
3365        assert_eq!(screen.overlays.active_query(), Some("#todo"));
3366
3367        // Ctrl+D (SaveCurrentQuery) while the note browser is active should
3368        // replace it with the save-search dialog, sourcing the browser's query.
3369        let save_event = KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL);
3370        {
3371            let s = settings.read().unwrap();
3372            let combo = key_event_to_combo(&save_event).expect("Ctrl+D maps to a combo");
3373            assert_eq!(
3374                s.key_bindings.get_action(&combo),
3375                Some(ActionShortcuts::SaveCurrentQuery),
3376                "test assumes Ctrl+D is bound to SaveCurrentQuery"
3377            );
3378        }
3379
3380        screen.handle_input(&InputEvent::Key(save_event), &tx);
3381
3382        assert_eq!(
3383            screen.overlays.active_kind(),
3384            Some(OverlayKind::Dialog),
3385            "saving from the note browser opens the save-search dialog"
3386        );
3387    }
3388
3389    /// A note that isn't there was never opened: offering to create it must
3390    /// not also record it as the last note, or the next launch lands on it.
3391    #[tokio::test]
3392    async fn opening_a_missing_note_leaves_the_history_alone() {
3393        use crate::settings::AppSettings;
3394        use std::sync::RwLock;
3395
3396        let vault = crate::test_support::temp_vault("editor-missing").await;
3397        vault
3398            .create_note(&VaultPath::new("here.md"), "text")
3399            .await
3400            .unwrap();
3401        let history_dir = tempfile::TempDir::new().unwrap();
3402        let settings: SharedSettings = Arc::new(RwLock::new(AppSettings::for_test_workspace(
3403            "ws",
3404            vault.workspace_path(),
3405            crate::test_support::sys(history_dir.path()),
3406        )));
3407        let mut screen = EditorScreen::new(vault, VaultPath::root(), settings.clone());
3408        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3409
3410        screen.open_path(VaultPath::new("here.md"), None, &tx).await;
3411        screen
3412            .open_path(VaultPath::new("missing.md"), None, &tx)
3413            .await;
3414
3415        assert_eq!(
3416            settings.read().unwrap().current_last_paths(),
3417            vec![VaultPath::new("here.md")]
3418        );
3419        assert_eq!(
3420            screen.overlays.active_kind(),
3421            Some(OverlayKind::Dialog),
3422            "the create-note offer still stands for an explicit open"
3423        );
3424    }
3425
3426    /// Renaming the open note keeps it open under the new path (retarget in
3427    /// place) instead of navigating away, and reloads the buffer clean.
3428    #[tokio::test(flavor = "multi_thread")]
3429    async fn renaming_open_note_retargets_in_place() {
3430        let vault = crate::test_support::temp_vault("editor-rename").await;
3431        vault.validate_and_init().await.unwrap();
3432        let from = VaultPath::note_path_from("old");
3433        vault.create_note(&from, "# Old\n\nbody").await.unwrap();
3434        let settings = std::sync::Arc::new(std::sync::RwLock::new(
3435            crate::settings::AppSettings::default(),
3436        ));
3437        let mut screen = EditorScreen::new(vault.clone(), from.clone(), settings);
3438        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3439        screen.on_enter(&tx).await;
3440
3441        let to = VaultPath::note_path_from("new");
3442        vault.rename_note(&from, &to).await.unwrap();
3443        screen
3444            .handle_app_message(
3445                AppEvent::FileOp(FileOp::Renamed {
3446                    from: from.clone(),
3447                    to: to.clone(),
3448                }),
3449                &tx,
3450            )
3451            .await;
3452
3453        assert_eq!(screen.path, to, "editor retargets to the new path");
3454        assert!(
3455            !screen.panels.editor().unwrap().is_dirty(),
3456            "reloaded buffer is clean (won't clobber the renamed file)"
3457        );
3458    }
3459
3460    /// Opening a note marks its sidebar row; saving it (AutosaveCompleted with a
3461    /// new title) updates that row's title in place.
3462    #[tokio::test(flavor = "multi_thread")]
3463    async fn open_then_save_marks_and_retitles_sidebar_row() {
3464        let vault = crate::test_support::temp_vault("editor-marksave").await;
3465        vault.validate_and_init().await.unwrap();
3466        vault
3467            .create_note(&VaultPath::note_path_from("alpha"), "# Alpha\n\nbody")
3468            .await
3469            .unwrap();
3470        let settings = std::sync::Arc::new(std::sync::RwLock::new(
3471            crate::settings::AppSettings::default(),
3472        ));
3473        let path = VaultPath::note_path_from("alpha");
3474        let mut screen = EditorScreen::new(vault.clone(), path.clone(), settings);
3475        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3476        screen.on_enter(&tx).await;
3477        for _ in 0..50 {
3478            screen.panels.sidebar_mut().poll_for_test();
3479            if !screen.panels.sidebar().is_loading_for_test() {
3480                break;
3481            }
3482            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
3483        }
3484
3485        assert!(
3486            screen
3487                .panels
3488                .sidebar()
3489                .note_row_is_open_for_test("alpha.md"),
3490            "the open note's row is marked"
3491        );
3492
3493        screen
3494            .handle_app_message(
3495                AppEvent::AutosaveCompleted {
3496                    path: path.clone(),
3497                    saved_revision: None,
3498                    title: Some("New First Line".to_string()),
3499                },
3500                &tx,
3501            )
3502            .await;
3503
3504        assert_eq!(
3505            screen.panels.sidebar().note_row_title_for_test("alpha.md"),
3506            Some("New First Line".to_string()),
3507            "the saved note's row title updated in place"
3508        );
3509    }
3510
3511    /// Switching the drawer view Ask → Files → Ask preserves the conversation:
3512    /// the resident thread panel is never dropped, so its turns are intact on
3513    /// the way back in (rules 1 & 2, CONTEXT.md: Thread lifetime).
3514    #[tokio::test]
3515    async fn ask_drawer_switch_preserves_thread() {
3516        let (mut screen, _, _, _dir) = test_screen().await;
3517        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3518
3519        // Enter the Ask workspace and seed a couple of turns.
3520        screen.open_drawer_view(DrawerView::Ask, &tx);
3521        assert!(screen.panels.is_showing_ask());
3522        {
3523            let panel = screen.panels.ask_mut();
3524            panel.thread_mut().ask("first?".into());
3525            panel.thread_mut().ask("second?".into());
3526        }
3527        assert_eq!(screen.panels.ask().thread().turns().len(), 2);
3528
3529        // Switch away: the editor area leaves Ask, but the resident thread
3530        // panel keeps the conversation.
3531        screen.open_drawer_view(DrawerView::Files, &tx);
3532        assert!(!screen.panels.is_showing_ask());
3533        assert_eq!(screen.panels.ask().thread().turns().len(), 2);
3534
3535        // Switch back: the same conversation is on screen.
3536        screen.open_drawer_view(DrawerView::Ask, &tx);
3537        assert!(screen.panels.is_showing_ask());
3538        assert_eq!(
3539            screen.panels.ask().thread().turns().len(),
3540            2,
3541            "the conversation survives the round trip"
3542        );
3543    }
3544
3545    /// An `AskData::AnswerReady` delivered through the screen's real message
3546    /// pump (`handle_app_message`, the same entry point the app loop uses —
3547    /// not the coordinator's `handle_data` called directly) must still land on
3548    /// the thread: it should route through `handle_owned_message`'s
3549    /// `AppEvent::Ask(data)` arm to `AskCoordinator::handle_data` end to end.
3550    #[tokio::test]
3551    async fn ask_answer_ready_reaches_the_thread_via_the_owned_message_path() {
3552        let (mut screen, _, _, _dir) = test_screen().await;
3553        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3554
3555        screen.open_drawer_view(DrawerView::Ask, &tx);
3556        let id = screen.panels.ask_mut().thread_mut().ask("q?".into());
3557
3558        screen
3559            .handle_app_message(
3560                AppEvent::Ask(crate::components::events::AskData::AnswerReady {
3561                    turn_id: id,
3562                    result: Ok(("the answer".into(), vec![])),
3563                }),
3564                &tx,
3565            )
3566            .await;
3567
3568        let panel = screen.panels.ask_mut();
3569        let turn = panel.thread().selected().expect("a turn is selected");
3570        assert!(matches!(turn.status, crate::ask::TurnStatus::Done));
3571        assert_eq!(turn.answer, "the answer");
3572    }
3573
3574    /// Opening a note from the browser while the Ask workspace is shown must
3575    /// swap the editor area to the note (carry-forward: `open_path` calls
3576    /// `clear_attachment`, which no-ops on Ask, so the stash is the only thing
3577    /// that gets the note on screen) — and the thread survives to be re-shown.
3578    #[tokio::test]
3579    async fn opening_a_note_over_ask_shows_the_note_and_keeps_the_thread() {
3580        let vault = crate::test_support::temp_vault("editor-ask-open").await;
3581        vault.validate_and_init().await.unwrap();
3582        vault
3583            .create_note(&VaultPath::note_path_from("alpha"), "# Alpha\n\nbody")
3584            .await
3585            .unwrap();
3586        let settings = std::sync::Arc::new(std::sync::RwLock::new(
3587            crate::settings::AppSettings::default(),
3588        ));
3589        let mut screen = EditorScreen::new(vault, VaultPath::root(), settings);
3590        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3591
3592        screen.open_drawer_view(DrawerView::Ask, &tx);
3593        screen
3594            .panels
3595            .ask_mut()
3596            .thread_mut()
3597            .ask("a question?".into());
3598        assert!(screen.panels.is_showing_ask());
3599
3600        // Open a note: the editor area must show it, not the thread.
3601        screen
3602            .open_path(VaultPath::note_path_from("alpha"), None, &tx)
3603            .await;
3604        assert!(!screen.panels.is_showing_ask(), "the note replaced Ask");
3605        assert!(screen.panels.editor().is_some(), "the note editor is shown");
3606        assert_eq!(
3607            screen.panels.active_drawer_view(),
3608            DrawerView::Files,
3609            "the Sources drawer must not linger on a hidden conversation"
3610        );
3611
3612        // Re-enter Ask: the resident conversation comes back intact.
3613        screen.open_drawer_view(DrawerView::Ask, &tx);
3614        assert_eq!(
3615            screen.panels.ask().thread().turns().len(),
3616            1,
3617            "the thread survived opening the note"
3618        );
3619    }
3620
3621    /// Opening a note while Ask is shown but the drawer was explicitly hidden
3622    /// must switch the drawer's view to FILES *without* re-revealing it — the
3623    /// view switch preserves visibility (fold-in #4). Before the fix the
3624    /// FILES switch went through `open_drawer_view`, which force-showed a
3625    /// drawer the user had hidden.
3626    #[tokio::test]
3627    async fn opening_a_note_over_ask_with_hidden_drawer_keeps_it_hidden() {
3628        let vault = crate::test_support::temp_vault("editor-ask-hidden-drawer").await;
3629        vault.validate_and_init().await.unwrap();
3630        vault
3631            .create_note(&VaultPath::note_path_from("alpha"), "# Alpha\n\nbody")
3632            .await
3633            .unwrap();
3634        let settings = std::sync::Arc::new(std::sync::RwLock::new(
3635            crate::settings::AppSettings::default(),
3636        ));
3637        let mut screen = EditorScreen::new(vault, VaultPath::root(), settings);
3638        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3639
3640        // Enter Ask, then hide the drawer while the workspace stays on screen.
3641        screen.open_drawer_view(DrawerView::Ask, &tx);
3642        screen.panels.hide(PanelKind::Drawer);
3643        assert!(!screen.panels.is_visible(PanelKind::Drawer));
3644        assert!(screen.panels.is_showing_ask());
3645
3646        // Open a note: Ask leaves the editor area and the drawer view switches
3647        // to FILES, but the drawer must stay hidden.
3648        screen
3649            .open_path(VaultPath::note_path_from("alpha"), None, &tx)
3650            .await;
3651        assert!(!screen.panels.is_showing_ask(), "the note replaced Ask");
3652        assert!(
3653            !screen.panels.is_visible(PanelKind::Drawer),
3654            "a hidden drawer must not be force-revealed"
3655        );
3656        assert_eq!(
3657            screen.panels.active_drawer_view(),
3658            DrawerView::Files,
3659            "the view switched off Ask to FILES"
3660        );
3661    }
3662
3663    /// Regression for the rail-click toggle bug: before the fix, opening a
3664    /// note over Ask left the drawer's active view stuck on `Ask` (only the
3665    /// editor-area content moved), so the next `OpenDrawerView(Ask)` — what a
3666    /// rail click sends — read as "already showing, toggle it closed"
3667    /// instead of "reopen it", requiring a second click. Drives the real
3668    /// `AppEvent::OpenDrawerView` path (not the direct `open_drawer_view`
3669    /// helper) so this test actually exercises that toggle branch.
3670    #[tokio::test]
3671    async fn rail_click_reenters_ask_in_one_click_after_a_note_stashed_it() {
3672        let vault = crate::test_support::temp_vault("editor-ask-rail-reentry").await;
3673        vault.validate_and_init().await.unwrap();
3674        vault
3675            .create_note(&VaultPath::note_path_from("alpha"), "# Alpha\n\nbody")
3676            .await
3677            .unwrap();
3678        let settings = std::sync::Arc::new(std::sync::RwLock::new(
3679            crate::settings::AppSettings::default(),
3680        ));
3681        let mut screen = EditorScreen::new(vault, VaultPath::root(), settings);
3682        screen.rag_status = crate::rag::RagStatus::Online {
3683            llm_available: true,
3684        };
3685        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3686
3687        screen
3688            .handle_app_message(AppEvent::OpenDrawerView(DrawerView::Ask), &tx)
3689            .await;
3690        assert!(screen.panels.is_showing_ask());
3691        screen
3692            .panels
3693            .ask_mut()
3694            .thread_mut()
3695            .ask("a question?".into());
3696
3697        screen
3698            .open_path(VaultPath::note_path_from("alpha"), None, &tx)
3699            .await;
3700        assert!(!screen.panels.is_showing_ask());
3701
3702        // One click — one `OpenDrawerView(Ask)` — must land back on Ask.
3703        screen
3704            .handle_app_message(AppEvent::OpenDrawerView(DrawerView::Ask), &tx)
3705            .await;
3706        assert!(
3707            screen.panels.is_showing_ask(),
3708            "a single reopen must not read as a toggle-off"
3709        );
3710        assert_eq!(
3711            screen.panels.ask().thread().turns().len(),
3712            1,
3713            "the thread survived the round trip"
3714        );
3715    }
3716
3717    /// Opening an attachment while the Ask workspace is shown must swap the
3718    /// editor area to the attachment view (`show_attachment` leaves Ask content
3719    /// alone, so the stash is the only thing that gets it off screen) — and the
3720    /// thread survives to be re-shown. Mirrors the open-note-over-Ask case for
3721    /// the attachment entry point.
3722    #[tokio::test]
3723    async fn opening_an_attachment_over_ask_shows_it_and_keeps_the_thread() {
3724        let vault = crate::test_support::temp_vault("editor-ask-open-att").await;
3725        vault.validate_and_init().await.unwrap();
3726        let att_path = VaultPath::new("pic.png");
3727        vault
3728            .save_attachment(&att_path, b"\x89PNG\r\n\x1a\n")
3729            .await
3730            .unwrap();
3731        let settings = std::sync::Arc::new(std::sync::RwLock::new(
3732            crate::settings::AppSettings::default(),
3733        ));
3734        let mut screen = EditorScreen::new(vault, VaultPath::root(), settings);
3735        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3736
3737        screen.open_drawer_view(DrawerView::Ask, &tx);
3738        screen
3739            .panels
3740            .ask_mut()
3741            .thread_mut()
3742            .ask("a question?".into());
3743        assert!(screen.panels.is_showing_ask());
3744
3745        // Open an attachment: the editor area must show it, not the thread.
3746        screen.open_attachment(att_path, &tx).await;
3747        assert!(
3748            !screen.panels.is_showing_ask(),
3749            "the attachment replaced Ask"
3750        );
3751
3752        // Re-enter Ask: the stashed conversation comes back intact.
3753        screen.open_drawer_view(DrawerView::Ask, &tx);
3754        assert_eq!(
3755            screen.panels.ask().thread().turns().len(),
3756            1,
3757            "the thread survived opening the attachment"
3758        );
3759    }
3760
3761    /// Client presence at the screen seam: with no LLM-capable server there is
3762    /// no client, so the resident Ask panel's composer is disabled (never a
3763    /// forever-`Thinking` turn; carry-forward #2).
3764    #[tokio::test]
3765    async fn refresh_ask_capability_disables_without_a_client() {
3766        let (mut screen, _, _, _dir) = test_screen().await;
3767
3768        // rag_status is Disabled here → no client → composer disabled.
3769        screen
3770            .ask
3771            .refresh_capability(&mut screen.panels, screen.rag_status)
3772            .await;
3773        assert!(
3774            !screen.panels.ask().has_client(),
3775            "the resident Ask panel loses its client without an LLM server"
3776        );
3777    }
3778
3779    /// An answer that lands while the user is browsing FILES (Ask not shown) is
3780    /// delivered to the resident thread, not dropped (rule 3).
3781    #[tokio::test]
3782    async fn answer_lands_on_the_thread_while_browsing_files() {
3783        let (mut screen, _, _, _dir) = test_screen().await;
3784        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3785
3786        screen.open_drawer_view(DrawerView::Ask, &tx);
3787        let turn_id = screen.panels.ask_mut().thread_mut().ask("q?".into());
3788        // Leave Ask: the in-flight turn stays on the resident panel.
3789        screen.open_drawer_view(DrawerView::Files, &tx);
3790        assert!(!screen.panels.is_showing_ask());
3791
3792        screen.ask.handle_data(
3793            &mut screen.panels,
3794            crate::components::events::AskData::AnswerReady {
3795                turn_id,
3796                result: Ok(("the answer".into(), vec![])),
3797            },
3798            &tx,
3799        );
3800
3801        let thread = screen.panels.ask().thread();
3802        let turn = thread.turns().iter().find(|t| t.id == turn_id).unwrap();
3803        assert!(
3804            matches!(turn.status, crate::ask::TurnStatus::Done),
3805            "the answer completed the turn"
3806        );
3807    }
3808
3809    /// SEM's rail entry is live-status-driven, exactly like ASK: hidden until a
3810    /// health probe reports the server reachable for search, shown for a
3811    /// semantic-only server (search works, ASK stays hidden), and hidden again
3812    /// when the server drops offline.
3813    #[tokio::test]
3814    async fn sem_rail_entry_tracks_search_availability() {
3815        let (mut screen, _, _, _dir) = test_screen().await;
3816        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3817
3818        // Startup: no status yet → SEM (and ASK) hidden.
3819        assert!(!screen.panels.rail_shows(DrawerView::Semantic));
3820        assert!(!screen.panels.rail_shows(DrawerView::Ask));
3821
3822        // Semantic-only server comes online: SEM appears, ASK stays hidden.
3823        screen
3824            .handle_owned_message(
3825                AppEvent::RagStatus(crate::rag::RagStatus::Online {
3826                    llm_available: false,
3827                }),
3828                &tx,
3829            )
3830            .await;
3831        assert!(screen.panels.rail_shows(DrawerView::Semantic));
3832        assert!(!screen.panels.rail_shows(DrawerView::Ask));
3833
3834        // Server drops offline: SEM disappears too (not driven by config).
3835        screen
3836            .handle_owned_message(AppEvent::RagStatus(crate::rag::RagStatus::Offline), &tx)
3837            .await;
3838        assert!(!screen.panels.rail_shows(DrawerView::Semantic));
3839
3840        // LLM-capable server: both SEM and ASK appear.
3841        screen
3842            .handle_owned_message(
3843                AppEvent::RagStatus(crate::rag::RagStatus::Online {
3844                    llm_available: true,
3845                }),
3846                &tx,
3847            )
3848            .await;
3849        assert!(screen.panels.rail_shows(DrawerView::Semantic));
3850        assert!(screen.panels.rail_shows(DrawerView::Ask));
3851    }
3852
3853    /// Leader `a a` (`open_ask_workspace`) gates on `llm_available` only for
3854    /// the enter-from-outside path. Once Ask is already showing, the
3855    /// sibling `a n`/`a y`/`a e`/`a r` actions all work on a degraded
3856    /// (capability-off) thread, so re-focusing the composer must be
3857    /// consistent with them rather than flashing the gate message again.
3858    #[tokio::test]
3859    async fn ask_focus_skips_the_llm_gate_when_already_showing_ask() {
3860        let (mut screen, _, _, _dir) = test_screen().await;
3861        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3862
3863        screen.rag_status = crate::rag::RagStatus::Online {
3864            llm_available: true,
3865        };
3866        screen.open_ask_workspace(&tx);
3867        assert!(screen.panels.is_showing_ask());
3868
3869        // Capability drops mid-conversation: the thread stays on screen with
3870        // the composer merely disabled, never evicted.
3871        screen.rag_status = crate::rag::RagStatus::Offline;
3872
3873        screen.open_ask_workspace(&tx);
3874        assert!(screen.panels.is_showing_ask(), "still showing Ask");
3875        assert_eq!(screen.panels.focused(), PanelKind::Editor);
3876        assert!(
3877            rx.try_recv().is_err(),
3878            "no gate flash while Ask is already showing"
3879        );
3880    }
3881
3882    // ── Following a link with the mouse: the screen's half of the rule ──────
3883    //
3884    // `ClickRun` owns which *mouse* events are one gesture and is tested
3885    // against a real event sequence there. What `track_click` owns is
3886    // everything the run cannot answer for itself — is this a mouse event, is
3887    // an overlay in the way, did the press land on the note buffer — and that
3888    // last one is a hit-test, which is where the frame-counted-as-buffer defect
3889    // lived.
3890
3891    /// Lay the screen out by rendering it, so the hit-test reads the rects a
3892    /// real frame produced rather than defaults.
3893    fn lay_out(screen: &mut EditorScreen) {
3894        let mut term = ratatui::Terminal::new(ratatui::backend::TestBackend::new(120, 40)).unwrap();
3895        term.draw(|f| screen.render(f)).unwrap();
3896    }
3897
3898    /// The buffer's top-left cell, found by asking the screen's own gate — so
3899    /// the coordinates cannot drift from the layout the way a literal would.
3900    fn buffer_origin(screen: &EditorScreen) -> (u16, u16) {
3901        (0..40u16)
3902            .flat_map(|row| (0..120u16).map(move |col| (col, row)))
3903            .find(|(col, row)| screen.panels.is_note_buffer_cell(*col, *row))
3904            .expect("a laid-out screen always shows the note buffer")
3905    }
3906
3907    /// A cell on the frame `PanelSet::render` draws around the editor: the
3908    /// corner one row above and one column left of the buffer's first cell.
3909    ///
3910    /// The assert is the point, not a formality. Deriving the probe from
3911    /// [`buffer_origin`] means the gate picks where the test looks, so a gate
3912    /// that answered "the editor column" would slide this probe out of the
3913    /// column with it and every frame assertion below would hold vacuously.
3914    /// Focus is the independent witness: `PanelSet::handle_mouse` focuses the
3915    /// panel whose *column* contains the press, and it is the column, not the
3916    /// buffer, that this cell has to be inside for the tests to mean anything.
3917    fn frame_corner(screen: &mut EditorScreen, tx: &AppTx) -> (u16, u16) {
3918        let (col, row) = buffer_origin(screen);
3919        let corner = (col - 1, row - 1);
3920        screen.panels.focus(PanelKind::Drawer);
3921        screen.handle_input(&press_at(corner.0, corner.1), tx);
3922        assert_eq!(
3923            screen.panels.focused(),
3924            PanelKind::Editor,
3925            "precondition: the frame is inside the editor column"
3926        );
3927        // That probe was a real press. Drop it so the run starts clean.
3928        screen.clicks.end();
3929        corner
3930    }
3931
3932    fn press_at(col: u16, row: u16) -> InputEvent {
3933        use ratatui::crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
3934        InputEvent::Mouse(MouseEvent {
3935            kind: MouseEventKind::Down(MouseButton::Left),
3936            column: col,
3937            row,
3938            modifiers: KeyModifiers::NONE,
3939        })
3940    }
3941
3942    fn moved_at(col: u16, row: u16) -> InputEvent {
3943        use ratatui::crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
3944        InputEvent::Mouse(MouseEvent {
3945            kind: MouseEventKind::Moved,
3946            column: col,
3947            row,
3948            modifiers: KeyModifiers::NONE,
3949        })
3950    }
3951
3952    /// A fixed origin, so the tests describe gaps rather than wall-clock time.
3953    fn at(millis: u64) -> std::time::Instant {
3954        static ORIGIN: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
3955        *ORIGIN.get_or_init(std::time::Instant::now) + std::time::Duration::from_millis(millis)
3956    }
3957
3958    #[tokio::test]
3959    async fn two_presses_on_the_buffer_are_a_double_click() {
3960        let (mut screen, _, _, _dir) = test_screen().await;
3961        lay_out(&mut screen);
3962        let (col, row) = buffer_origin(&screen);
3963
3964        assert!(!screen.track_click(&press_at(col, row), at(0)));
3965        assert!(screen.track_click(&press_at(col, row), at(120)));
3966    }
3967
3968    /// The gate is not the editor *column*: `PanelSet::render` frames the
3969    /// component, and a press on that frame never reaches the component's
3970    /// `handle_mouse`, so the cursor stays where it was. Counting those cells
3971    /// let two clicks on the `─ Editor` title bar follow whichever link the
3972    /// cursor was parked on — a note the user never pointed at.
3973    #[tokio::test]
3974    async fn a_press_on_the_editor_frame_is_never_half_of_a_double() {
3975        let (mut screen, _, _, _dir) = test_screen().await;
3976        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3977        lay_out(&mut screen);
3978        let (col, row) = frame_corner(&mut screen, &tx);
3979
3980        assert!(!screen.track_click(&press_at(col, row), at(0)));
3981        assert!(
3982            !screen.track_click(&press_at(col, row), at(120)),
3983            "two clicks on the frame are not a follow"
3984        );
3985    }
3986
3987    /// A frame press does not merely fail to pair — it ends the run, so it
3988    /// cannot sit unnoticed between two buffer presses and let them pair
3989    /// across it.
3990    #[tokio::test]
3991    async fn a_frame_press_separates_two_buffer_presses() {
3992        let (mut screen, _, _, _dir) = test_screen().await;
3993        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3994        lay_out(&mut screen);
3995        let (frame_col, frame_row) = frame_corner(&mut screen, &tx);
3996        let (col, row) = buffer_origin(&screen);
3997
3998        screen.track_click(&press_at(col, row), at(0));
3999        screen.track_click(&press_at(frame_col, frame_row), at(40));
4000        assert!(!screen.track_click(&press_at(col, row), at(80)));
4001    }
4002
4003    /// Only a press consults the hit-test. Any-event tracking delivers a
4004    /// `Moved` per cell of travel, including over the frame, and the pointer
4005    /// crossing the border mid-gesture is not the user doing anything.
4006    #[tokio::test]
4007    async fn motion_over_the_frame_does_not_break_a_double() {
4008        let (mut screen, _, _, _dir) = test_screen().await;
4009        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
4010        lay_out(&mut screen);
4011        let (frame_col, frame_row) = frame_corner(&mut screen, &tx);
4012        let (col, row) = buffer_origin(&screen);
4013
4014        screen.track_click(&press_at(col, row), at(0));
4015        assert!(!screen.track_click(&moved_at(frame_col, frame_row), at(40)));
4016        assert!(screen.track_click(&press_at(col, row), at(120)));
4017    }
4018
4019    #[tokio::test]
4020    async fn a_key_between_presses_ends_the_click_run() {
4021        let (mut screen, _, _, _dir) = test_screen().await;
4022        lay_out(&mut screen);
4023        let (col, row) = buffer_origin(&screen);
4024
4025        screen.track_click(&press_at(col, row), at(0));
4026        assert!(!screen.track_click(&chr('x'), at(40)));
4027        assert!(!screen.track_click(&press_at(col, row), at(80)));
4028    }
4029
4030    /// An overlay ends the run rather than merely failing to extend it. A modal
4031    /// is drawn over the editor column and a `SearchList` activates a row on a
4032    /// click-click of its own; without ending it, those presses stay in the run
4033    /// and pair with the first one that lands after the overlay closes.
4034    #[tokio::test]
4035    async fn an_overlay_ends_the_click_run() {
4036        let (mut screen, _, _, _dir) = test_screen().await;
4037        lay_out(&mut screen);
4038        let (col, row) = buffer_origin(&screen);
4039        let vault = screen.vault.clone();
4040
4041        screen.track_click(&press_at(col, row), at(0));
4042        screen.present_overlay(Box::new(ActiveDialog::create_note(
4043            VaultPath::note_path_from("x"),
4044            vault,
4045            None,
4046        )));
4047        assert!(
4048            !screen.track_click(&press_at(col, row), at(40)),
4049            "a press under an overlay is the overlay's"
4050        );
4051        screen.dismiss_overlay();
4052        assert!(
4053            !screen.track_click(&press_at(col, row), at(80)),
4054            "and neither press survives to pair with the next one"
4055        );
4056    }
4057
4058    /// End to end: the second press of a pair over a wikilink emits the same
4059    /// `FollowLink` the bound shortcut does. The first press is what placed the
4060    /// cursor the second one asks about, which is why one path serves both.
4061    #[tokio::test]
4062    async fn a_double_click_on_a_wikilink_follows_it() {
4063        let (mut screen, _, _, _dir) = test_screen().await;
4064        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4065        screen
4066            .panels
4067            .editor_mut()
4068            .unwrap()
4069            .set_text("[[target note]]".to_string());
4070        lay_out(&mut screen);
4071        // Mid-link, so the exact gutter width the editor renders cannot move
4072        // the press off the span.
4073        let (col, row) = buffer_origin(&screen);
4074        let (col, row) = (col + 6, row);
4075
4076        screen.handle_input(&press_at(col, row), &tx);
4077        screen.handle_input(&press_at(col, row), &tx);
4078
4079        let followed = std::iter::from_fn(|| rx.try_recv().ok())
4080            .find_map(|ev| match ev {
4081                AppEvent::FollowLink(target) => Some(target),
4082                _ => None,
4083            })
4084            .expect("the double-click emitted no FollowLink");
4085        assert_eq!(followed, "target note");
4086    }
4087
4088    /// The first press only places the cursor. Following on a single click is
4089    /// the behaviour this replaced: a stray click while reading would navigate.
4090    #[tokio::test]
4091    async fn a_single_press_on_a_wikilink_only_places_the_cursor() {
4092        let (mut screen, _, _, _dir) = test_screen().await;
4093        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
4094        screen
4095            .panels
4096            .editor_mut()
4097            .unwrap()
4098            .set_text("[[target note]]".to_string());
4099        lay_out(&mut screen);
4100        let (col, row) = buffer_origin(&screen);
4101
4102        screen.handle_input(&press_at(col + 6, row), &tx);
4103
4104        assert!(
4105            !std::iter::from_fn(|| rx.try_recv().ok())
4106                .any(|ev| matches!(ev, AppEvent::FollowLink(_))),
4107            "one click is a cursor placement, not a navigation"
4108        );
4109        assert!(
4110            screen
4111                .panels
4112                .editor()
4113                .unwrap()
4114                .follow_target_at_cursor()
4115                .is_some(),
4116            "but it did land on the link, so the next press has something to follow"
4117        );
4118    }
4119}
4120
4121#[cfg(test)]
4122mod sort_routing_tests {
4123    use super::*;
4124    use crate::app_screen::AppScreen;
4125    use crate::components::events::SortTarget;
4126    use crate::components::file_list::{SortField, SortOrder};
4127
4128    async fn make_editor() -> (
4129        EditorScreen,
4130        AppTx,
4131        tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
4132    ) {
4133        let vault = crate::test_support::temp_vault("editor-sort").await;
4134        vault.validate_and_init().await.unwrap();
4135        let settings = std::sync::Arc::new(std::sync::RwLock::new(
4136            crate::settings::AppSettings::default(),
4137        ));
4138        let screen = EditorScreen::new(vault, VaultPath::root(), settings);
4139        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
4140        (screen, tx, rx)
4141    }
4142
4143    #[tokio::test(flavor = "multi_thread")]
4144    async fn sort_save_default_persists_to_settings() {
4145        let (mut screen, tx, _rx) = make_editor().await;
4146        screen
4147            .handle_app_message(
4148                AppEvent::SortChanged {
4149                    target: SortTarget::Sidebar,
4150                    field: SortField::Title,
4151                    order: SortOrder::Descending,
4152                    group_directories: true,
4153                    persist: true,
4154                },
4155                &tx,
4156            )
4157            .await;
4158        let s = screen.settings.read().unwrap();
4159        assert_eq!(
4160            s.default_sort_field,
4161            crate::settings::SortFieldSetting::Title
4162        );
4163        assert_eq!(
4164            s.default_sort_order,
4165            crate::settings::SortOrderSetting::Descending
4166        );
4167        assert!(s.group_directories);
4168    }
4169
4170    #[tokio::test(flavor = "multi_thread")]
4171    async fn sort_save_default_journal_dir_writes_journal_settings() {
4172        let (mut screen, tx, _rx) = make_editor().await;
4173        // Point the sidebar at the journal directory (current_dir set synchronously).
4174        let journal = screen.vault.journal_path().clone();
4175        screen.panels.sidebar_mut().navigate(journal, &tx);
4176
4177        screen
4178            .handle_app_message(
4179                AppEvent::SortChanged {
4180                    target: SortTarget::Sidebar,
4181                    field: SortField::Title,
4182                    order: SortOrder::Ascending,
4183                    group_directories: false,
4184                    persist: true,
4185                },
4186                &tx,
4187            )
4188            .await;
4189
4190        let s = screen.settings.read().unwrap();
4191        assert_eq!(
4192            s.journal_sort_field,
4193            crate::settings::SortFieldSetting::Title
4194        );
4195        assert_eq!(
4196            s.journal_sort_order,
4197            crate::settings::SortOrderSetting::Ascending
4198        );
4199        // Default (non-journal) settings must be untouched from their defaults.
4200        assert_eq!(
4201            s.default_sort_field,
4202            crate::settings::SortFieldSetting::Name
4203        );
4204    }
4205
4206    /// A non-persisting SortChanged (a plain dialog toggle) applies live but
4207    /// must NOT write settings.
4208    #[tokio::test(flavor = "multi_thread")]
4209    async fn sort_changed_without_persist_leaves_settings() {
4210        let (mut screen, tx, _rx) = make_editor().await;
4211        screen
4212            .handle_app_message(
4213                AppEvent::SortChanged {
4214                    target: SortTarget::Sidebar,
4215                    field: SortField::Title,
4216                    order: SortOrder::Descending,
4217                    group_directories: true,
4218                    persist: false,
4219                },
4220                &tx,
4221            )
4222            .await;
4223        let s = screen.settings.read().unwrap();
4224        assert_eq!(
4225            s.default_sort_field,
4226            crate::settings::SortFieldSetting::Name
4227        );
4228        assert_eq!(
4229            s.default_sort_order,
4230            crate::settings::SortOrderSetting::Ascending
4231        );
4232        assert!(!s.group_directories);
4233    }
4234}