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