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