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