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