Skip to main content

fresh/app/
prompt_lifecycle.rs

1//! Prompt/minibuffer lifecycle on `Editor`.
2//!
3//! Starting/canceling/confirming prompts, scrolling suggestions,
4//! managing prompt history per type, building suggestion lists, plus
5//! the file-open/quick-open prompt setup helpers.
6
7use std::path::PathBuf;
8use std::sync::{Arc, RwLock};
9
10use rust_i18n::t;
11
12use crate::input::command_registry::CommandRegistry;
13use crate::input::commands::Suggestion;
14use crate::input::keybindings::KeyContext;
15use crate::input::quick_open::{BufferInfo, QuickOpenContext};
16use crate::services::async_bridge::AsyncMessage;
17use crate::services::plugins::PluginManager;
18use crate::view::prompt::{Prompt, PromptType};
19
20use super::file_open;
21use super::window::Window;
22use super::Editor;
23
24impl Editor {
25    // Prompt/Minibuffer control methods
26
27    /// Start a new prompt (enter minibuffer mode)
28    pub fn start_prompt(&mut self, message: String, prompt_type: PromptType) {
29        self.start_prompt_with_suggestions(message, prompt_type, Vec::new());
30    }
31
32    /// Start a search prompt with an optional selection scope
33    ///
34    /// When `use_selection_range` is true and a single-line selection is present,
35    /// the search will be restricted to that range once confirmed.
36    pub(super) fn start_search_prompt(
37        &mut self,
38        message: String,
39        prompt_type: PromptType,
40        use_selection_range: bool,
41    ) {
42        // Reset any previously stored selection range
43        self.active_window_mut().pending_search_range = None;
44
45        let selection_range = self.active_cursors().primary().selection_range();
46
47        let selected_text = if let Some(range) = selection_range.clone() {
48            let state = self.active_state_mut();
49            let text = state.get_text_range(range.start, range.end);
50            if !text.contains('\n') && !text.is_empty() {
51                Some(text)
52            } else {
53                None
54            }
55        } else {
56            None
57        };
58
59        if use_selection_range {
60            self.active_window_mut().pending_search_range = selection_range;
61        }
62
63        // Determine the default text: selection > last history > empty
64        let from_history = selected_text.is_none();
65        let default_text = selected_text.or_else(|| {
66            self.get_prompt_history("search")
67                .and_then(|h| h.last().map(|s| s.to_string()))
68        });
69
70        // Start the prompt
71        self.start_prompt(message, prompt_type);
72
73        // Pre-fill with default text if available
74        if let Some(text) = default_text {
75            if let Some(ref mut prompt) = self.active_window_mut().prompt {
76                prompt.set_input(text.clone());
77                prompt.selection_anchor = Some(0);
78                prompt.cursor_pos = text.len();
79            }
80            if from_history {
81                self.get_or_create_prompt_history("search").init_at_last();
82            }
83            self.update_search_highlights(&text);
84        }
85    }
86
87    /// Start a new prompt with autocomplete suggestions
88    pub fn start_prompt_with_suggestions(
89        &mut self,
90        message: String,
91        prompt_type: PromptType,
92        suggestions: Vec<Suggestion>,
93    ) {
94        // Dismiss transient popups and clear hover state when opening a prompt
95        self.active_window_mut().on_editor_focus_lost();
96
97        // Clear search highlights when starting a new search prompt
98        // This ensures old highlights from previous searches don't persist
99        match prompt_type {
100            PromptType::Search | PromptType::ReplaceSearch | PromptType::QueryReplaceSearch => {
101                self.active_window_mut().clear_search_highlights();
102            }
103            _ => {}
104        }
105
106        // Check if we need to update suggestions after creating the prompt
107        let needs_suggestions = matches!(
108            prompt_type,
109            PromptType::OpenFile | PromptType::SwitchProject | PromptType::SaveFileAs
110        );
111
112        self.active_window_mut().prompt =
113            Some(Prompt::with_suggestions(message, prompt_type, suggestions));
114
115        // For file and command prompts, populate initial suggestions
116        if needs_suggestions {
117            self.update_prompt_suggestions();
118        }
119    }
120
121    /// Start a new prompt with initial text
122    pub fn start_prompt_with_initial_text(
123        &mut self,
124        message: String,
125        prompt_type: PromptType,
126        initial_text: String,
127    ) {
128        // Dismiss transient popups and clear hover state when opening a prompt
129        self.active_window_mut().on_editor_focus_lost();
130
131        self.active_window_mut().prompt = Some(Prompt::with_initial_text(
132            message,
133            prompt_type,
134            initial_text,
135        ));
136    }
137
138    /// Start Quick Open prompt with command palette as default
139    pub fn start_quick_open(&mut self) {
140        self.start_quick_open_with_prefix(">");
141    }
142
143    /// Start Quick Open prompt with specified prefix
144    pub fn start_quick_open_with_prefix(&mut self, prefix: &str) {
145        self.active_window_mut().on_editor_focus_lost();
146        self.active_window_mut().status_message = None;
147        self.active_window_mut().goto_line_preview = None;
148
149        let mut prompt = Prompt::with_suggestions(String::new(), PromptType::QuickOpen, vec![]);
150        prompt.input = prefix.to_string();
151        prompt.cursor_pos = prefix.len();
152        self.active_window_mut().prompt = Some(prompt);
153
154        self.update_quick_open_suggestions(prefix);
155    }
156
157    /// Build a QuickOpenContext from current editor state
158    pub(super) fn build_quick_open_context(&self) -> QuickOpenContext {
159        let metadata = &self.active_window().buffer_metadata;
160        let open_buffers = self
161            .buffers()
162            .iter()
163            .filter_map(|(buffer_id, state)| {
164                let meta = metadata.get(buffer_id);
165                // Mirror the tab bar: skip buffers that aren't shown as tabs
166                // (composite source buffers, the synthetic blank placeholder).
167                if meta.is_some_and(|m| m.hidden_from_tabs || m.synthetic_placeholder) {
168                    return None;
169                }
170                match state.buffer.file_path() {
171                    Some(path) => {
172                        let name = path
173                            .file_name()
174                            .map(|n| n.to_string_lossy().to_string())
175                            .unwrap_or_else(|| format!("Buffer {}", buffer_id.0));
176                        Some(BufferInfo {
177                            id: buffer_id.0,
178                            path: path.display().to_string(),
179                            name,
180                            modified: state.buffer.is_modified(),
181                            is_virtual: false,
182                        })
183                    }
184                    // No file path: only virtual buffers (plugin panels like
185                    // *blame:…*, *Git Log*) are surfaced — by their tab name —
186                    // so they're reachable without clicking the tab (#2373).
187                    // Unnamed scratch buffers stay out, as before.
188                    None => {
189                        let meta = meta.filter(|m| m.is_virtual())?;
190                        Some(BufferInfo {
191                            id: buffer_id.0,
192                            path: String::new(),
193                            name: meta.display_name.clone(),
194                            modified: state.buffer.is_modified(),
195                            is_virtual: true,
196                        })
197                    }
198                }
199            })
200            .collect();
201
202        let has_lsp_config = {
203            let language = self
204                .buffers()
205                .get(&self.active_buffer())
206                .map(|s| s.language.as_str());
207            language
208                .and_then(|lang| self.lsp().and_then(|lsp| lsp.get_config(lang)))
209                .is_some()
210        };
211
212        QuickOpenContext {
213            cwd: self.working_dir().display().to_string(),
214            open_buffers,
215            active_buffer_id: self.active_buffer().0,
216            active_buffer_path: self
217                .active_state()
218                .buffer
219                .file_path()
220                .map(|p| p.display().to_string()),
221            has_selection: self.has_active_selection(),
222            key_context: self.active_window().key_context.clone(),
223            custom_contexts: self.active_window().active_custom_contexts.clone(),
224            buffer_mode: self
225                .active_window()
226                .buffer_metadata
227                .get(&self.active_buffer())
228                .and_then(|m| m.virtual_mode())
229                .map(|s| s.to_string()),
230            has_lsp_config,
231            relative_line_numbers: self.config.editor.relative_line_numbers,
232        }
233    }
234
235    /// Update Quick Open suggestions based on current input, dispatching through the registry
236    pub(super) fn update_quick_open_suggestions(&mut self, input: &str) {
237        let context = self.build_quick_open_context();
238        let suggestions = if let Some((provider, query)) =
239            self.quick_open_registry.get_provider_for_input(input)
240        {
241            provider.suggestions(query, &context)
242        } else {
243            vec![]
244        };
245
246        if let Some(prompt) = &mut self.active_window_mut().prompt {
247            prompt.suggestions = suggestions;
248            prompt.selected_suggestion = if prompt.suggestions.is_empty() {
249                None
250            } else {
251                Some(0)
252            };
253        }
254
255        // Live preview for the goto-line provider: if the input is ":<N>" for a
256        // valid absolute line N, jump there now so the user sees the target as
257        // they type (matches VSCode's Ctrl+P :<N> behavior). Otherwise, restore
258        // the cursor to its pre-preview position.
259        //
260        // Relative input (`:+N`/`:-N`) is intentionally not previewed: the
261        // target shifts on every digit typed, which is disorienting.
262        let input = input.trim();
263        let target = Self::parse_quick_open_goto_line_target(input);
264        self.apply_goto_line_preview(target);
265    }
266
267    /// Parse a Quick Open input string for a `:<N>` goto-line preview target.
268    /// Only absolute inputs are previewed; relative inputs return `None`.
269    pub(super) fn parse_quick_open_goto_line_target(input: &str) -> Option<usize> {
270        let rest = input.strip_prefix(':')?;
271        match crate::input::quick_open::parse_goto_line_input(rest) {
272            Some(crate::input::quick_open::GotoLineTarget::Absolute(n)) => Some(n),
273            _ => None,
274        }
275    }
276
277    /// Apply a live goto-line preview: jump to `target_line` (saving the
278    /// original cursor on the first jump) if `Some`, or restore the saved
279    /// cursor if `None`.
280    ///
281    /// Shared between Quick Open's `:N` syntax and the standalone `Goto Line`
282    /// prompt, which differ only in how the target line is parsed from input.
283    pub(super) fn apply_goto_line_preview(&mut self, target_line: Option<usize>) {
284        if let Some(line) = target_line {
285            self.save_goto_line_preview_snapshot();
286            self.goto_line_col(line, None);
287            // Record where the jump landed so restore can detect if the cursor
288            // has since moved (e.g., mouse click, external buffer edit).
289            let new_position = self.active_cursors().primary().position;
290            if let Some(snap) = self.active_window_mut().goto_line_preview.as_mut() {
291                snap.last_jump_position = new_position;
292            }
293        } else {
294            self.restore_goto_line_preview_snapshot();
295        }
296    }
297
298    /// Save a snapshot of the active buffer's cursor and viewport so the
299    /// goto-line preview can later restore it. No-op if a snapshot is already
300    /// in place (the saved state should always be the pre-preview one).
301    pub(super) fn save_goto_line_preview_snapshot(&mut self) {
302        if self.active_window_mut().goto_line_preview.is_some() {
303            return;
304        }
305
306        let buffer_id = self.active_buffer();
307        let split_id = self
308            .windows
309            .get(&self.active_window)
310            .and_then(|w| w.buffers.splits())
311            .map(|(mgr, _)| mgr)
312            .expect("active window must have a populated split layout")
313            .active_split();
314        let (cursor_id, position, anchor, sticky_column) = {
315            let cursors = self.active_cursors();
316            let primary = cursors.primary();
317            (
318                cursors.primary_id(),
319                primary.position,
320                primary.anchor,
321                primary.sticky_column,
322            )
323        };
324        let (viewport_top_byte, viewport_top_view_line_offset, viewport_left_column) = {
325            let vp = self.active_viewport();
326            (vp.top_byte, vp.top_view_line_offset, vp.left_column)
327        };
328
329        self.active_window_mut().goto_line_preview = Some(super::GotoLinePreviewSnapshot {
330            buffer_id,
331            split_id,
332            cursor_id,
333            position,
334            anchor,
335            sticky_column,
336            viewport_top_byte,
337            viewport_top_view_line_offset,
338            viewport_left_column,
339            // Before the first jump the cursor is still at the pre-preview
340            // position; `apply_goto_line_preview` overwrites this with the
341            // jump target immediately after calling `goto_line_col`.
342            last_jump_position: position,
343        });
344    }
345
346    /// If a goto-line preview snapshot exists, restore the active split's
347    /// cursor and viewport to the saved state and clear the snapshot.
348    ///
349    /// The snapshot is only applied if the editor is still in exactly the
350    /// state the last preview jump left it in: same active buffer, same split,
351    /// cursor still at `last_jump_position`. Any deviation (user mouse-clicked,
352    /// an async edit shifted the cursor, focus moved elsewhere, …) means the
353    /// pre-preview state is stale and we simply discard the snapshot.
354    pub(super) fn restore_goto_line_preview_snapshot(&mut self) {
355        let Some(snap) = self.active_window_mut().goto_line_preview.take() else {
356            return;
357        };
358
359        // If the active buffer/split has changed (shouldn't happen during a
360        // quick-open prompt, but be defensive), just drop the snapshot.
361        if self.active_buffer() != snap.buffer_id
362            || self
363                .windows
364                .get(&self.active_window)
365                .and_then(|w| w.buffers.splits())
366                .map(|(mgr, _)| mgr)
367                .expect("active window must have a populated split layout")
368                .active_split()
369                != snap.split_id
370        {
371            return;
372        }
373
374        let cursors = self.active_cursors();
375        let current = cursors.primary();
376
377        // Cursor no longer where the preview left it → someone else moved it
378        // (mouse click, external edit via `adjust_for_edit`, …). Drop without
379        // restoring to avoid rubber-banding over that deliberate state.
380        if current.position != snap.last_jump_position {
381            return;
382        }
383        let event = crate::model::event::Event::MoveCursor {
384            cursor_id: snap.cursor_id,
385            old_position: current.position,
386            new_position: snap.position,
387            old_anchor: current.anchor,
388            new_anchor: snap.anchor,
389            old_sticky_column: current.sticky_column,
390            new_sticky_column: snap.sticky_column,
391        };
392
393        self.active_window_mut()
394            .apply_event_to_buffer(snap.buffer_id, snap.split_id, &event);
395
396        if let Some(view_state) = self
397            .active_window_mut()
398            .buffers
399            .splits_mut()
400            .expect("active window must have a populated split layout")
401            .1
402            .get_mut(&snap.split_id)
403        {
404            let vp = &mut view_state.viewport;
405            vp.top_byte = snap.viewport_top_byte;
406            vp.top_view_line_offset = snap.viewport_top_view_line_offset;
407            vp.left_column = snap.viewport_left_column;
408            // The cursor we just restored is already consistent with this
409            // viewport; don't let ensure_visible re-scroll on the next render.
410            vp.set_skip_ensure_visible();
411        }
412    }
413
414    /// Pre-fill the Open File prompt input with the current buffer directory
415    pub(super) fn prefill_open_file_prompt(&mut self) {
416        // With the native file browser, the directory is shown from file_open_state.current_dir
417        // in the prompt rendering. The prompt.input is just the filter/filename, so we
418        // start with an empty input.
419        if let Some(prompt) = self.active_window_mut().prompt.as_mut() {
420            if prompt.prompt_type == PromptType::OpenFile {
421                prompt.input.clear();
422                prompt.cursor_pos = 0;
423                prompt.selection_anchor = None;
424            }
425        }
426    }
427
428    /// Initialize the file open dialog state
429    ///
430    /// Called when the Open File prompt is started. Determines the initial directory
431    /// (from current buffer's directory or working directory) and triggers async
432    /// directory loading.
433    pub(super) fn init_file_open_state(&mut self) {
434        // Determine initial directory
435        let buffer_id = self.active_buffer();
436
437        // For terminal buffers, use the terminal's initial CWD or fall back to project root
438        // This avoids showing the terminal backing file directory which is confusing for users
439        let initial_dir = if self.active_window().is_terminal_buffer(buffer_id) {
440            self.active_window()
441                .get_terminal_id(buffer_id)
442                .and_then(|tid| self.active_window().terminal_manager.get(tid))
443                .and_then(|handle| handle.cwd())
444                .unwrap_or_else(|| self.working_dir().to_path_buf())
445        } else {
446            self.active_state()
447                .buffer
448                .file_path()
449                .and_then(|path| path.parent())
450                .map(|p| p.to_path_buf())
451                .unwrap_or_else(|| self.working_dir().to_path_buf())
452        };
453
454        // Create the file open state with config-based show_hidden setting
455        let show_hidden = self.config.file_browser.show_hidden;
456        self.active_window_mut().file_open_state = Some(file_open::FileOpenState::new(
457            initial_dir.clone(),
458            show_hidden,
459            self.authority().filesystem.clone(),
460        ));
461
462        // Start async directory loading and async shortcuts loading in parallel
463        self.load_file_open_directory(initial_dir);
464        self.load_file_open_shortcuts_async();
465    }
466
467    /// Initialize the folder open dialog state
468    ///
469    /// Called when the Switch Project prompt is started. Starts from the current working
470    /// directory and triggers async directory loading.
471    pub(super) fn init_folder_open_state(&mut self) {
472        // Start from the current working directory
473        let initial_dir = self.working_dir().to_path_buf();
474
475        // Create the file open state with config-based show_hidden setting
476        let show_hidden = self.config.file_browser.show_hidden;
477        self.active_window_mut().file_open_state = Some(file_open::FileOpenState::new(
478            initial_dir.clone(),
479            show_hidden,
480            self.authority().filesystem.clone(),
481        ));
482
483        // Start async directory loading and async shortcuts loading in parallel
484        self.load_file_open_directory(initial_dir);
485        self.load_file_open_shortcuts_async();
486    }
487
488    /// Change the active window's project root to a new path (Switch Project).
489    ///
490    /// Re-roots ONLY the active window — opens the new project in a fresh
491    /// window, dives into it, and closes the window it was invoked from. It
492    /// deliberately does **not** restart the editor: the previous
493    /// implementation requested a full process restart that tore down and
494    /// rebuilt *every* window from disk, which discarded the live state of all
495    /// other open workspaces (terminals, agents, LSP) and silently downgraded
496    /// remote siblings to a local backend. With the create→dive→close flow,
497    /// every other window and the Orchestrator dock are left untouched.
498    ///
499    /// A remote active window carries its connected backend (and connection
500    /// keepalive) onto the new project so the new root opens on the same
501    /// container / SSH host; a local window gets a fresh local authority
502    /// scoped to the new root.
503    pub fn change_working_dir(&mut self, new_path: PathBuf) {
504        // Canonicalize the path to resolve symlinks and normalize
505        let new_path = new_path.canonicalize().unwrap_or(new_path);
506        let old_id = self.active_window;
507
508        // Already showing this root in the active window — nothing to do.
509        if self
510            .find_window_by_root(&new_path)
511            .is_some_and(|w| w == old_id)
512        {
513            return;
514        }
515
516        // Persist the outgoing project's layout first so a later switch back
517        // restores its buffers/splits.
518        #[allow(clippy::let_underscore_must_use)]
519        let _ = self.save_workspace_for(old_id);
520
521        let new_id = if let Some(existing) = self.find_window_by_root(&new_path) {
522            // The new project is already open in another window — re-home onto
523            // it rather than spawning a duplicate session for the same dir.
524            existing
525        } else {
526            let label = new_path
527                .file_name()
528                .map(|n| n.to_string_lossy().into_owned())
529                .unwrap_or_else(|| new_path.to_string_lossy().into_owned());
530
531            if self
532                .windows
533                .get(&old_id)
534                .is_some_and(|w| w.authority_spec.is_remote())
535            {
536                // Carry the active window's connected backend onto the new
537                // project so it opens on the same container / SSH host (the
538                // Switch Project browser listed that backend's filesystem, so
539                // the picked path lives there). `Authority` is non-`Clone`
540                // (one per window, issue #2280), so move it out of the old
541                // window — which is closed moments later — and move the
542                // connection keepalive across so closing the old window can't
543                // tear the backend down.
544                let spec = self
545                    .windows
546                    .get(&old_id)
547                    .map(|w| w.authority_spec.clone())
548                    .unwrap_or_default();
549                let authority = self.take_active_authority();
550                let id = self.create_window_with_authority(new_path.clone(), label, authority);
551                if let Some(w) = self.windows.get_mut(&id) {
552                    w.authority_spec = spec;
553                }
554                if let Some(keepalive) = self.session_keepalives.remove(&old_id) {
555                    self.session_keepalives.insert(id, keepalive);
556                }
557                id
558            } else {
559                // Local window: the new project gets its own fresh local
560                // authority scoped to the new root (its own per-session trust).
561                self.create_window_at(new_path.clone(), label)
562            }
563        };
564
565        if new_id == old_id {
566            return;
567        }
568
569        self.set_active_window(new_id);
570        self.close_window(old_id);
571
572        // Re-evaluate workspace trust for the freshly-rooted project.
573        self.maybe_prompt_workspace_trust();
574    }
575
576    /// Load directory contents for the file open dialog
577    pub(super) fn load_file_open_directory(&mut self, path: PathBuf) {
578        // Update state to loading
579        if let Some(state) = &mut self.active_window_mut().file_open_state {
580            state.current_dir = path.clone();
581            state.loading = true;
582            state.error = None;
583            state.update_shortcuts();
584        }
585
586        // Use tokio runtime to load directory. Use the *active window's*
587        // fs_manager (which rides that window's authority) so the Open File
588        // browser lists the remote host for a remote window, not the local box.
589        if let Some(ref runtime) = self.tokio_runtime {
590            let fs_manager = self.active_window().resources.fs_manager.clone();
591            let sender = self.async_bridge.as_ref().map(|b| b.sender());
592
593            runtime.spawn(async move {
594                let result = fs_manager.list_dir_with_metadata(path).await;
595                if let Some(sender) = sender {
596                    // Receiver may have been dropped if the dialog was closed.
597                    #[allow(clippy::let_underscore_must_use)]
598                    let _ = sender.send(AsyncMessage::FileOpenDirectoryLoaded(result));
599                }
600            });
601        } else {
602            // No runtime, set error
603            if let Some(state) = &mut self.active_window_mut().file_open_state {
604                state.set_error("Async runtime not available".to_string());
605            }
606        }
607    }
608
609    /// Handle file open directory load result
610    pub(super) fn handle_file_open_directory_loaded(
611        &mut self,
612        result: std::io::Result<Vec<crate::services::fs::DirEntry>>,
613    ) {
614        match result {
615            Ok(entries) => {
616                if let Some(state) = &mut self.active_window_mut().file_open_state {
617                    state.set_entries(entries);
618                }
619                // Re-apply filter from prompt (entries were just loaded, filter needs to select matching entry)
620                let filter = self
621                    .active_window()
622                    .prompt
623                    .as_ref()
624                    .map(|p| p.input.clone())
625                    .unwrap_or_default();
626                if !filter.is_empty() {
627                    if let Some(state) = &mut self.active_window_mut().file_open_state {
628                        state.apply_filter(&filter);
629                    }
630                }
631            }
632            Err(e) => {
633                if let Some(state) = &mut self.active_window_mut().file_open_state {
634                    state.set_error(e.to_string());
635                }
636            }
637        }
638    }
639
640    /// Load async shortcuts (documents, downloads, Windows drive letters) in the background.
641    /// This prevents the UI from hanging when checking paths that may be slow or unreachable.
642    /// See issue #903.
643    pub(super) fn load_file_open_shortcuts_async(&mut self) {
644        if let Some(ref runtime) = self.tokio_runtime {
645            let filesystem = self.authority().filesystem.clone();
646            let sender = self.async_bridge.as_ref().map(|b| b.sender());
647
648            runtime.spawn(async move {
649                // Run the blocking filesystem checks in a separate thread
650                let shortcuts = tokio::task::spawn_blocking(move || {
651                    file_open::FileOpenState::build_shortcuts_async(&*filesystem)
652                })
653                .await
654                .unwrap_or_default();
655
656                if let Some(sender) = sender {
657                    // Receiver may have been dropped if the dialog was closed.
658                    #[allow(clippy::let_underscore_must_use)]
659                    let _ = sender.send(AsyncMessage::FileOpenShortcutsLoaded(shortcuts));
660                }
661            });
662        }
663    }
664
665    /// Handle async shortcuts load result
666    pub(super) fn handle_file_open_shortcuts_loaded(
667        &mut self,
668        shortcuts: Vec<file_open::NavigationShortcut>,
669    ) {
670        if let Some(state) = &mut self.active_window_mut().file_open_state {
671            state.merge_async_shortcuts(shortcuts);
672        }
673    }
674
675    /// Tear down the standalone preview state used by the Live Grep
676    /// floating overlay (issue #1796). Drops the inline view state
677    /// and closes any buffers we loaded purely for preview (buffers
678    /// the user already had open are left untouched).
679    pub(crate) fn cleanup_overlay_preview(&mut self) {
680        let (to_close, last_buffer): (Vec<crate::model::event::BufferId>, _) =
681            if let Some(state) = self.active_window_mut().overlay_preview_state.take() {
682                let last = state.buffer_id;
683                (state.loaded_buffers.into_iter().collect(), Some(last))
684            } else {
685                (Vec::new(), None)
686            };
687        // Scrub the preview's search-match overlays from the last buffer it
688        // pointed at. Redundant for preview-loaded buffers (closed below),
689        // but essential for buffers the user already had open so the
690        // highlights don't outlive the overlay.
691        if let Some(last) = last_buffer {
692            let ns = crate::view::overlay::OverlayNamespace::from_string(
693                "overlay-preview-search".to_string(),
694            );
695            if let Some(state) = self.active_window_mut().buffers.get_mut(&last) {
696                state.overlays.clear_namespace(&ns, &mut state.marker_list);
697            }
698        }
699        for buffer_id in to_close {
700            // close_buffer is the user-facing close (errors on
701            // unsaved changes). Preview-loaded buffers are read-only
702            // / unmodified by definition, so this should always
703            // succeed. Tolerate failure silently — leaving an extra
704            // hidden buffer around is preferable to crashing.
705            if let Err(e) = self.close_buffer(buffer_id) {
706                tracing::warn!("Failed to close overlay preview buffer: {}", e);
707            }
708        }
709    }
710
711    /// Snapshot the current prompt's suggestions as a list of
712    /// `GrepMatch` records, so Resume can re-display them without
713    /// re-running ripgrep and Quickfix export can hand them to the
714    /// Utility Dock. Parses each suggestion's text as
715    /// `path:line[:col]` (the format the live_grep finder emits).
716    pub(crate) fn snapshot_prompt_results_for_grep(
717        &self,
718        prompt: &crate::view::prompt::Prompt,
719    ) -> Vec<crate::services::live_grep_state::GrepMatch> {
720        use crate::input::quick_open::parse_path_line_col;
721        // Suggestions emitted by the Finder library use `value` as an
722        // opaque index (`"0"`, `"1"`, …) and put `path:line[:col]` in
723        // `text`. Parse `text` first; fall back to `value` only if
724        // `text` lacks a path-shaped segment (a Resume-replay where
725        // we previously stored `path:line:col` in `value` directly).
726        prompt
727            .suggestions
728            .iter()
729            .filter(|s| !s.disabled)
730            .filter_map(|s| {
731                let from_text = parse_path_line_col(&s.text);
732                let (file, line, column) = if !from_text.0.is_empty() && from_text.1.is_some() {
733                    from_text
734                } else if let Some(v) = s.value.as_deref() {
735                    parse_path_line_col(v)
736                } else {
737                    from_text
738                };
739                if file.is_empty() {
740                    return None;
741                }
742                Some(crate::services::live_grep_state::GrepMatch {
743                    file,
744                    line: line.unwrap_or(1),
745                    column: column.unwrap_or(1),
746                    content: s.description.clone().unwrap_or_default(),
747                })
748            })
749            .collect()
750    }
751
752    /// Cancel the current prompt and return to normal mode
753    pub fn cancel_prompt(&mut self) {
754        // Extract theme to restore if this is a SelectTheme prompt
755        let theme_to_restore = if let Some(ref prompt) = self.active_window_mut().prompt {
756            if let PromptType::SelectTheme { original_theme } = &prompt.prompt_type {
757                Some(original_theme.clone())
758            } else {
759                None
760            }
761        } else {
762            None
763        };
764
765        // Determine prompt type and reset appropriate history navigation.
766        // Clone the prompt so subsequent self.X mutations (history,
767        // plugin hooks, file_open_state, live_grep_last_state) don't
768        // conflict with the borrow on self.active_window().prompt.
769        let prompt_clone = self.active_window().prompt.clone();
770        if let Some(prompt) = prompt_clone {
771            let prompt = &prompt;
772            // Reset history navigation for this prompt type
773            if let Some(key) = Self::prompt_type_to_history_key(&prompt.prompt_type) {
774                if let Some(history) = self.active_window_mut().prompt_histories.get_mut(&key) {
775                    history.reset_navigation();
776                }
777            }
778            match &prompt.prompt_type {
779                PromptType::Search | PromptType::ReplaceSearch | PromptType::QueryReplaceSearch => {
780                    self.active_window_mut().clear_search_highlights();
781                }
782                PromptType::Plugin { custom_type } => {
783                    // Fire plugin hook for prompt cancellation
784                    use crate::services::plugins::hooks::HookArgs;
785                    self.plugin_manager.read().unwrap().run_hook(
786                        "prompt_cancelled",
787                        HookArgs::PromptCancelled {
788                            prompt_type: custom_type.clone(),
789                            input: prompt.input.clone(),
790                        },
791                    );
792                    // Capture Live Grep state on cancel for Resume
793                    // (Action::ResumeLiveGrep) — reads from
794                    // editor.live_grep_last_state. Detection by
795                    // custom_type rather than dedicated PromptType
796                    // because the live_grep plugin drives the prompt.
797                    if custom_type == "live-grep" {
798                        let cached = self.snapshot_prompt_results_for_grep(prompt);
799                        // Only cache when there's something useful to
800                        // resume — dismissing the prompt before
801                        // typing or before any results streamed in
802                        // shouldn't mark the cache as "valid", or
803                        // Resume sees `cached_results.is_some()`
804                        // (empty Vec) and enters the restore branch
805                        // with zero entries, producing an empty
806                        // popup.
807                        if !prompt.input.is_empty() && !cached.is_empty() {
808                            self.active_window_mut().live_grep_last_state =
809                                Some(crate::services::live_grep_state::LiveGrepLastState {
810                                    query: prompt.input.clone(),
811                                    selected_index: prompt.selected_suggestion,
812                                    cached_results: Some(cached),
813                                    cached_at: Some(std::time::Instant::now()),
814                                    last_results_snapshot_id: None,
815                                });
816                        }
817                    }
818                }
819                PromptType::LiveGrep => {
820                    let cached = self.snapshot_prompt_results_for_grep(prompt);
821                    if !prompt.input.is_empty() && !cached.is_empty() {
822                        self.active_window_mut().live_grep_last_state =
823                            Some(crate::services::live_grep_state::LiveGrepLastState {
824                                query: prompt.input.clone(),
825                                selected_index: prompt.selected_suggestion,
826                                cached_results: Some(cached),
827                                cached_at: Some(std::time::Instant::now()),
828                                last_results_snapshot_id: None,
829                            });
830                    }
831                }
832                PromptType::LspRename { overlay_handle, .. } => {
833                    // Remove the rename overlay when cancelling
834                    let remove_overlay_event = crate::model::event::Event::RemoveOverlay {
835                        handle: overlay_handle.clone(),
836                    };
837                    self.apply_event_to_active_buffer(&remove_overlay_event);
838                }
839                PromptType::OpenFile | PromptType::SwitchProject | PromptType::SaveFileAs => {
840                    // Clear file browser state
841                    self.active_window_mut().file_open_state = None;
842                    self.active_window_mut().file_browser_layout = None;
843
844                    // Cancelling a Save-As that was opened as part of the
845                    // "save and quit" chain aborts the quit — the user
846                    // explicitly chose not to name this buffer, so we'd
847                    // rather keep the editor open than drop their content.
848                    if matches!(prompt.prompt_type, PromptType::SaveFileAs)
849                        && !self
850                            .active_window_mut()
851                            .pending_quit_unnamed_save
852                            .is_empty()
853                    {
854                        self.active_window_mut().pending_quit_unnamed_save.clear();
855                        self.set_status_message(t!("buffer.close_cancelled").to_string());
856                    }
857                }
858                PromptType::AsyncPrompt => {
859                    // Resolve the pending async prompt callback with null (cancelled)
860                    if let Some(callback_id) = self
861                        .active_window_mut()
862                        .pending_async_prompt_callback
863                        .take()
864                    {
865                        self.plugin_manager
866                            .read()
867                            .unwrap()
868                            .resolve_callback(callback_id, "null".to_string());
869                    }
870                }
871                PromptType::QuickOpen => {
872                    // Cancel any in-progress background file loading
873                    if let Some((provider, _)) = self.quick_open_registry.get_provider_for_input("")
874                    {
875                        if let Some(fp) = provider
876                            .as_any()
877                            .downcast_ref::<crate::input::quick_open::providers::FileProvider>(
878                        ) {
879                            fp.cancel_loading();
880                        }
881                    }
882                    // Undo any live goto-line preview so the cursor returns to
883                    // where it was before the prompt was opened.
884                    self.restore_goto_line_preview_snapshot();
885                }
886                PromptType::GotoLine => {
887                    // Undo any live goto-line preview so the cursor returns to
888                    // where it was before the prompt was opened.
889                    self.restore_goto_line_preview_snapshot();
890                }
891                _ => {}
892            }
893        }
894
895        // If we're closing a floating-overlay prompt (Live Grep,
896        // issue #1796), tear down the phantom preview leaf and close
897        // any buffers we loaded purely to feed the preview pane. The
898        // user's split tree and originally-open buffers are
899        // untouched.
900        let was_overlay = self
901            .active_window()
902            .prompt
903            .as_ref()
904            .is_some_and(|p| p.overlay);
905        if was_overlay {
906            self.cleanup_overlay_preview();
907        }
908
909        self.active_window_mut().prompt = None;
910        self.active_window_mut().pending_search_range = None;
911        self.active_window_mut().status_message = Some(t!("search.cancelled").to_string());
912
913        // Restore original theme if we were in SelectTheme prompt
914        if let Some(original_theme) = theme_to_restore {
915            self.preview_theme(&original_theme);
916        }
917    }
918
919    /// Handle mouse wheel scroll in prompt with suggestions.
920    /// Returns true if scroll was handled, false if no prompt is active or has no suggestions.
921    pub fn handle_prompt_scroll(&mut self, delta: i32) -> bool {
922        if let Some(ref mut prompt) = self.active_window_mut().prompt {
923            if prompt.suggestions.is_empty() {
924                return false;
925            }
926
927            let current = prompt.selected_suggestion.unwrap_or(0);
928            let len = prompt.suggestions.len();
929
930            // Calculate new position based on scroll direction
931            // delta < 0 = scroll up, delta > 0 = scroll down
932            let new_selected = if delta < 0 {
933                // Scroll up - move selection up (decrease index)
934                current.saturating_sub((-delta) as usize)
935            } else {
936                // Scroll down - move selection down (increase index)
937                (current + delta as usize).min(len.saturating_sub(1))
938            };
939
940            prompt.selected_suggestion = Some(new_selected);
941
942            // Update input to match selected suggestion for non-plugin prompts
943            if !matches!(prompt.prompt_type, PromptType::Plugin { .. }) {
944                if let Some(suggestion) = prompt.suggestions.get(new_selected) {
945                    prompt.input = suggestion.get_value().to_string();
946                    prompt.cursor_pos = prompt.input.len();
947                }
948            }
949
950            return true;
951        }
952        false
953    }
954
955    /// Get the confirmed input and prompt type, consuming the prompt
956    /// For command palette, returns the selected suggestion if available, otherwise the raw input
957    /// Returns (input, prompt_type, selected_index)
958    /// Returns None if trying to confirm a disabled command
959    pub fn confirm_prompt(&mut self) -> Option<(String, PromptType, Option<usize>)> {
960        if let Some(prompt) = self.active_window_mut().prompt.take() {
961            // Capture Live Grep state on confirm too (issue #1796).
962            // `cancel_prompt` already does this; without it here,
963            // pressing Enter on a result jumps to the file but loses
964            // the Resume cache, so `Action::ResumeLiveGrep` then opens
965            // a fresh-empty popup instead of returning the user to
966            // their match list. Same gates as cancel: only cache when
967            // query and snapshot are both non-empty.
968            let is_live_grep = match &prompt.prompt_type {
969                PromptType::LiveGrep => true,
970                PromptType::Plugin { custom_type } => custom_type == "live-grep",
971                _ => false,
972            };
973            if is_live_grep {
974                let cached = self.snapshot_prompt_results_for_grep(&prompt);
975                if !prompt.input.is_empty() && !cached.is_empty() {
976                    self.active_window_mut().live_grep_last_state =
977                        Some(crate::services::live_grep_state::LiveGrepLastState {
978                            query: prompt.input.clone(),
979                            selected_index: prompt.selected_suggestion,
980                            cached_results: Some(cached),
981                            cached_at: Some(std::time::Instant::now()),
982                            last_results_snapshot_id: None,
983                        });
984                }
985            }
986            // Tear down the floating-overlay preview state on
987            // confirm too — the user is committing to a result and
988            // navigating to it, so the preview-only buffers should
989            // be cleaned up the same way they are on cancel.
990            if prompt.overlay {
991                self.cleanup_overlay_preview();
992            }
993            let selected_index = prompt.selected_suggestion;
994            // For prompts with suggestions, prefer the selected suggestion over raw input
995            let mut final_input = if prompt.sync_input_on_navigate {
996                // When sync_input_on_navigate is set, the input field is kept in sync
997                // with the selected suggestion, so always use the input value
998                prompt.input.clone()
999            } else if matches!(
1000                prompt.prompt_type,
1001                PromptType::OpenFile
1002                    | PromptType::SwitchProject
1003                    | PromptType::SaveFileAs
1004                    | PromptType::StopLspServer
1005                    | PromptType::RestartLspServer
1006                    | PromptType::SelectTheme { .. }
1007                    | PromptType::SelectLocale
1008                    | PromptType::SwitchToTab
1009                    | PromptType::SetLanguage
1010                    | PromptType::SetEncoding
1011                    | PromptType::SetLineEnding
1012                    | PromptType::Plugin { .. }
1013                    // Resume re-opens Live Grep as a core-driven
1014                    // PromptType::LiveGrep whose suggestions carry the
1015                    // match location in `value`. Resolve to the selected
1016                    // suggestion's value here, while the prompt still
1017                    // exists — the confirm handler runs after the prompt
1018                    // is taken and can no longer look it up.
1019                    | PromptType::LiveGrep
1020            ) {
1021                // Use the selected suggestion if any
1022                if let Some(selected_idx) = prompt.selected_suggestion {
1023                    if let Some(suggestion) = prompt.suggestions.get(selected_idx) {
1024                        // Don't confirm disabled suggestions
1025                        if suggestion.disabled {
1026                            self.set_status_message(
1027                                t!(
1028                                    "error.command_not_available",
1029                                    command = suggestion.text.clone()
1030                                )
1031                                .to_string(),
1032                            );
1033                            return None;
1034                        }
1035                        // Use the selected suggestion value
1036                        suggestion.get_value().to_string()
1037                    } else {
1038                        prompt.input.clone()
1039                    }
1040                } else {
1041                    prompt.input.clone()
1042                }
1043            } else {
1044                prompt.input.clone()
1045            };
1046
1047            // For StopLspServer/RestartLspServer, validate that the input matches a suggestion
1048            if matches!(
1049                prompt.prompt_type,
1050                PromptType::StopLspServer | PromptType::RestartLspServer
1051            ) {
1052                let is_valid = prompt
1053                    .suggestions
1054                    .iter()
1055                    .any(|s| s.text == final_input || s.get_value() == final_input);
1056                if !is_valid {
1057                    // Restore the prompt and don't confirm
1058                    self.active_window_mut().prompt = Some(prompt);
1059                    self.set_status_message(
1060                        t!("error.no_lsp_match", input = final_input.clone()).to_string(),
1061                    );
1062                    return None;
1063                }
1064            }
1065
1066            // For RemoveRuler, validate input against the suggestion list.
1067            // If the user typed text, it must match a suggestion value to be accepted.
1068            // If the input is empty, the pre-selected suggestion is used.
1069            if matches!(prompt.prompt_type, PromptType::RemoveRuler) {
1070                if prompt.input.is_empty() {
1071                    // No typed text — use the selected suggestion
1072                    if let Some(selected_idx) = prompt.selected_suggestion {
1073                        if let Some(suggestion) = prompt.suggestions.get(selected_idx) {
1074                            final_input = suggestion.get_value().to_string();
1075                        }
1076                    } else {
1077                        self.active_window_mut().prompt = Some(prompt);
1078                        return None;
1079                    }
1080                } else {
1081                    // User typed text — it must match a suggestion value
1082                    let typed = prompt.input.trim().to_string();
1083                    let matched = prompt.suggestions.iter().find(|s| s.get_value() == typed);
1084                    if let Some(suggestion) = matched {
1085                        final_input = suggestion.get_value().to_string();
1086                    } else {
1087                        // Typed text doesn't match any ruler — reject
1088                        self.active_window_mut().prompt = Some(prompt);
1089                        return None;
1090                    }
1091                }
1092            }
1093
1094            // Add to appropriate history based on prompt type
1095            if let Some(key) = Self::prompt_type_to_history_key(&prompt.prompt_type) {
1096                let history = self.get_or_create_prompt_history(&key);
1097                history.push(final_input.clone());
1098                history.reset_navigation();
1099            }
1100
1101            Some((final_input, prompt.prompt_type, selected_index))
1102        } else {
1103            None
1104        }
1105    }
1106
1107    /// Check if currently in prompt mode
1108    pub fn is_prompting(&self) -> bool {
1109        self.active_window().prompt.is_some()
1110    }
1111
1112    /// Whether the active prompt is a search/replace prompt that exposes the
1113    /// match-mode toggles (case sensitive / whole word / regex). Gates both
1114    /// the search-options bar and the `ToggleSearch*` actions so those keys
1115    /// stay inert in unrelated prompts (e.g. the save/discard/cancel close
1116    /// confirmation).
1117    pub fn active_prompt_has_search_options(&self) -> bool {
1118        self.active_window()
1119            .prompt
1120            .as_ref()
1121            .is_some_and(|p| p.prompt_type.has_search_options())
1122    }
1123
1124    /// Get or create a prompt history for the given key
1125    pub(super) fn get_or_create_prompt_history(
1126        &mut self,
1127        key: &str,
1128    ) -> &mut crate::input::input_history::InputHistory {
1129        self.active_window_mut()
1130            .prompt_histories
1131            .entry(key.to_string())
1132            .or_default()
1133    }
1134
1135    /// Get a prompt history for the given key (immutable)
1136    pub(super) fn get_prompt_history(
1137        &self,
1138        key: &str,
1139    ) -> Option<&crate::input::input_history::InputHistory> {
1140        self.active_window().prompt_histories.get(key)
1141    }
1142
1143    /// Get the history key for a prompt type
1144    pub(super) fn prompt_type_to_history_key(
1145        prompt_type: &crate::view::prompt::PromptType,
1146    ) -> Option<String> {
1147        use crate::view::prompt::PromptType;
1148        match prompt_type {
1149            PromptType::Search | PromptType::ReplaceSearch | PromptType::QueryReplaceSearch => {
1150                Some("search".to_string())
1151            }
1152            PromptType::Replace { .. } | PromptType::QueryReplace { .. } => {
1153                Some("replace".to_string())
1154            }
1155            PromptType::GotoLine => Some("goto_line".to_string()),
1156            PromptType::Plugin { custom_type } => Some(format!("plugin:{}", custom_type)),
1157            _ => None,
1158        }
1159    }
1160
1161    /// Get the current global editor mode (e.g., "vi-normal", "vi-insert")
1162    /// Returns None if no special mode is active
1163    pub fn editor_mode(&self) -> Option<String> {
1164        self.active_window().editor_mode.clone()
1165    }
1166
1167    /// Get access to the command registry
1168    pub fn command_registry(&self) -> &Arc<RwLock<CommandRegistry>> {
1169        &self.command_registry
1170    }
1171
1172    /// Get access to the plugin manager (read lock).
1173    ///
1174    /// Callers should use `editor.plugin_manager()` instead of locking
1175    /// directly so they're decoupled from the field's `Arc<RwLock<>>`
1176    /// internals.
1177    pub fn plugin_manager(&self) -> std::sync::RwLockReadGuard<'_, PluginManager> {
1178        self.plugin_manager.read().unwrap()
1179    }
1180
1181    /// Mutable access to the plugin manager (write lock).
1182    pub fn plugin_manager_mut(&mut self) -> std::sync::RwLockWriteGuard<'_, PluginManager> {
1183        self.plugin_manager.write().unwrap()
1184    }
1185
1186    /// Check if file explorer has focus
1187    pub fn file_explorer_is_focused(&self) -> bool {
1188        self.active_window().key_context == KeyContext::FileExplorer
1189    }
1190
1191    /// Get current prompt input (for display)
1192    pub fn prompt_input(&self) -> Option<&str> {
1193        self.active_window()
1194            .prompt
1195            .as_ref()
1196            .map(|p| p.input.as_str())
1197    }
1198
1199    /// Check if the active cursor currently has a selection
1200    pub fn has_active_selection(&self) -> bool {
1201        self.active_cursors().primary().selection_range().is_some()
1202    }
1203
1204    /// Get mutable reference to prompt (for input handling)
1205    pub fn prompt_mut(&mut self) -> Option<&mut Prompt> {
1206        self.active_window_mut().prompt.as_mut()
1207    }
1208
1209    /// Set a status message to display in the active window's status
1210    /// bar. Editor-side thin wrapper; per-window body lives in
1211    /// `Window::set_status_message`.
1212    pub fn set_status_message(&mut self, message: String) {
1213        self.active_window_mut().set_status_message(message);
1214    }
1215
1216    /// Get the current status message
1217    pub fn get_status_message(&self) -> Option<&String> {
1218        self.active_window()
1219            .plugin_status_message
1220            .as_ref()
1221            .or(self.active_window().status_message.as_ref())
1222    }
1223
1224    /// Get accumulated plugin errors (for test assertions)
1225    /// Returns all error messages that were detected in plugin status messages
1226    pub fn get_plugin_errors(&self) -> &[String] {
1227        &self.active_window().plugin_errors
1228    }
1229
1230    /// Clear accumulated plugin errors
1231    pub fn clear_plugin_errors(&mut self) {
1232        self.active_window_mut().plugin_errors.clear();
1233    }
1234
1235    /// Update prompt suggestions based on current input
1236    pub fn update_prompt_suggestions(&mut self) {
1237        // Extract prompt type and input to avoid borrow checker issues
1238        let (prompt_type, input) = if let Some(prompt) = &self.active_window_mut().prompt {
1239            (prompt.prompt_type.clone(), prompt.input.clone())
1240        } else {
1241            return;
1242        };
1243
1244        match prompt_type {
1245            PromptType::QuickOpen => {
1246                // Update Quick Open suggestions based on prefix
1247                self.update_quick_open_suggestions(&input);
1248            }
1249            PromptType::Search | PromptType::ReplaceSearch | PromptType::QueryReplaceSearch => {
1250                // Update incremental search highlights as user types
1251                self.update_search_highlights(&input);
1252                // Reset history navigation when user types - allows Up to navigate history
1253                if let Some(history) = self.active_window_mut().prompt_histories.get_mut("search") {
1254                    history.reset_navigation();
1255                }
1256            }
1257            PromptType::Replace { .. } | PromptType::QueryReplace { .. } => {
1258                // Reset history navigation when user types - allows Up to navigate history
1259                if let Some(history) = self.active_window_mut().prompt_histories.get_mut("replace")
1260                {
1261                    history.reset_navigation();
1262                }
1263            }
1264            PromptType::GotoLine => {
1265                // Reset history navigation when user types - allows Up to navigate Up arrow history
1266                if let Some(history) = self
1267                    .active_window_mut()
1268                    .prompt_histories
1269                    .get_mut("goto_line")
1270                {
1271                    history.reset_navigation();
1272                }
1273                // Live preview for absolute line numbers only. Signed
1274                // (`+N`/`-N`) inputs are relative, and previewing them as the
1275                // user types each digit is disorienting — preview only on
1276                // Enter for those.
1277                let target = match crate::input::quick_open::parse_goto_line_input(input.trim()) {
1278                    Some(crate::input::quick_open::GotoLineTarget::Absolute(n)) => Some(n),
1279                    _ => None,
1280                };
1281                self.apply_goto_line_preview(target);
1282            }
1283            PromptType::OpenFile | PromptType::SwitchProject | PromptType::SaveFileAs => {
1284                // For OpenFile/SwitchProject/SaveFileAs, update the file browser filter (native implementation)
1285                self.update_file_open_filter();
1286            }
1287            PromptType::Plugin { custom_type } => {
1288                // Reset history navigation when user types - allows Up to navigate history
1289                let key = format!("plugin:{}", custom_type);
1290                if let Some(history) = self.active_window_mut().prompt_histories.get_mut(&key) {
1291                    history.reset_navigation();
1292                }
1293                // Fire plugin hook for prompt input change
1294                use crate::services::plugins::hooks::HookArgs;
1295                self.plugin_manager.read().unwrap().run_hook(
1296                    "prompt_changed",
1297                    HookArgs::PromptChanged {
1298                        prompt_type: custom_type,
1299                        input,
1300                    },
1301                );
1302                // Apply fuzzy filtering if original_suggestions is set.
1303                // Note: filter_suggestions checks suggestions_set_for_input to skip
1304                // filtering if the plugin has already provided filtered results for
1305                // this input (handles the async race condition with run_hook).
1306                if let Some(prompt) = &mut self.active_window_mut().prompt {
1307                    prompt.filter_suggestions(false);
1308                }
1309            }
1310            PromptType::SwitchToTab
1311            | PromptType::SelectTheme { .. }
1312            | PromptType::StopLspServer
1313            | PromptType::RestartLspServer
1314            | PromptType::SetLanguage
1315            | PromptType::SetEncoding
1316            | PromptType::SetLineEnding => {
1317                if let Some(prompt) = &mut self.active_window_mut().prompt {
1318                    prompt.filter_suggestions(false);
1319                }
1320            }
1321            PromptType::SelectLocale => {
1322                // Locale selection also matches on description (language names)
1323                if let Some(prompt) = &mut self.active_window_mut().prompt {
1324                    prompt.filter_suggestions(true);
1325                }
1326            }
1327            _ => {}
1328        }
1329    }
1330}
1331
1332impl Window {
1333    /// Cancel search/replace prompts if one is active.
1334    /// Called when focus leaves the editor (e.g., switching buffers, focusing file explorer).
1335    pub(crate) fn cancel_search_prompt_if_active(&mut self) {
1336        if let Some(ref prompt) = self.prompt {
1337            if matches!(
1338                prompt.prompt_type,
1339                PromptType::Search
1340                    | PromptType::ReplaceSearch
1341                    | PromptType::Replace { .. }
1342                    | PromptType::QueryReplaceSearch
1343                    | PromptType::QueryReplace { .. }
1344                    | PromptType::QueryReplaceConfirm
1345            ) {
1346                self.prompt = None;
1347                // Also cancel interactive replace if active
1348                self.interactive_replace_state = None;
1349                // Clear search highlights from current buffer
1350                let ns = self.search_namespace.clone();
1351                let state = self.active_state_mut();
1352                state.overlays.clear_namespace(&ns, &mut state.marker_list);
1353            }
1354        }
1355    }
1356}