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    /// Cancel the current prompt and return to normal mode
577    pub fn cancel_prompt(&mut self) {
578        // Extract theme to restore if this is a SelectTheme prompt
579        let theme_to_restore = if let Some(ref prompt) = self.prompt {
580            if let PromptType::SelectTheme { original_theme } = &prompt.prompt_type {
581                Some(original_theme.clone())
582            } else {
583                None
584            }
585        } else {
586            None
587        };
588
589        // Determine prompt type and reset appropriate history navigation
590        if let Some(ref prompt) = self.prompt {
591            // Reset history navigation for this prompt type
592            if let Some(key) = Self::prompt_type_to_history_key(&prompt.prompt_type) {
593                if let Some(history) = self.prompt_histories.get_mut(&key) {
594                    history.reset_navigation();
595                }
596            }
597            match &prompt.prompt_type {
598                PromptType::Search | PromptType::ReplaceSearch | PromptType::QueryReplaceSearch => {
599                    self.clear_search_highlights();
600                }
601                PromptType::Plugin { custom_type } => {
602                    // Fire plugin hook for prompt cancellation
603                    use crate::services::plugins::hooks::HookArgs;
604                    self.plugin_manager.run_hook(
605                        "prompt_cancelled",
606                        HookArgs::PromptCancelled {
607                            prompt_type: custom_type.clone(),
608                            input: prompt.input.clone(),
609                        },
610                    );
611                }
612                PromptType::LspRename { overlay_handle, .. } => {
613                    // Remove the rename overlay when cancelling
614                    let remove_overlay_event = crate::model::event::Event::RemoveOverlay {
615                        handle: overlay_handle.clone(),
616                    };
617                    self.apply_event_to_active_buffer(&remove_overlay_event);
618                }
619                PromptType::OpenFile | PromptType::SwitchProject | PromptType::SaveFileAs => {
620                    // Clear file browser state
621                    self.file_open_state = None;
622                    self.file_browser_layout = None;
623                }
624                PromptType::AsyncPrompt => {
625                    // Resolve the pending async prompt callback with null (cancelled)
626                    if let Some(callback_id) = self.pending_async_prompt_callback.take() {
627                        self.plugin_manager
628                            .resolve_callback(callback_id, "null".to_string());
629                    }
630                }
631                PromptType::QuickOpen => {
632                    // Cancel any in-progress background file loading
633                    if let Some((provider, _)) = self.quick_open_registry.get_provider_for_input("")
634                    {
635                        if let Some(fp) = provider
636                            .as_any()
637                            .downcast_ref::<crate::input::quick_open::providers::FileProvider>(
638                        ) {
639                            fp.cancel_loading();
640                        }
641                    }
642                    // Undo any live goto-line preview so the cursor returns to
643                    // where it was before the prompt was opened.
644                    self.restore_goto_line_preview_snapshot();
645                }
646                PromptType::GotoLine => {
647                    // Undo any live goto-line preview so the cursor returns to
648                    // where it was before the prompt was opened.
649                    self.restore_goto_line_preview_snapshot();
650                }
651                _ => {}
652            }
653        }
654
655        self.prompt = None;
656        self.pending_search_range = None;
657        self.status_message = Some(t!("search.cancelled").to_string());
658
659        // Restore original theme if we were in SelectTheme prompt
660        if let Some(original_theme) = theme_to_restore {
661            self.preview_theme(&original_theme);
662        }
663    }
664
665    /// Handle mouse wheel scroll in prompt with suggestions.
666    /// Returns true if scroll was handled, false if no prompt is active or has no suggestions.
667    pub fn handle_prompt_scroll(&mut self, delta: i32) -> bool {
668        if let Some(ref mut prompt) = self.prompt {
669            if prompt.suggestions.is_empty() {
670                return false;
671            }
672
673            let current = prompt.selected_suggestion.unwrap_or(0);
674            let len = prompt.suggestions.len();
675
676            // Calculate new position based on scroll direction
677            // delta < 0 = scroll up, delta > 0 = scroll down
678            let new_selected = if delta < 0 {
679                // Scroll up - move selection up (decrease index)
680                current.saturating_sub((-delta) as usize)
681            } else {
682                // Scroll down - move selection down (increase index)
683                (current + delta as usize).min(len.saturating_sub(1))
684            };
685
686            prompt.selected_suggestion = Some(new_selected);
687
688            // Update input to match selected suggestion for non-plugin prompts
689            if !matches!(prompt.prompt_type, PromptType::Plugin { .. }) {
690                if let Some(suggestion) = prompt.suggestions.get(new_selected) {
691                    prompt.input = suggestion.get_value().to_string();
692                    prompt.cursor_pos = prompt.input.len();
693                }
694            }
695
696            return true;
697        }
698        false
699    }
700
701    /// Get the confirmed input and prompt type, consuming the prompt
702    /// For command palette, returns the selected suggestion if available, otherwise the raw input
703    /// Returns (input, prompt_type, selected_index)
704    /// Returns None if trying to confirm a disabled command
705    pub fn confirm_prompt(&mut self) -> Option<(String, PromptType, Option<usize>)> {
706        if let Some(prompt) = self.prompt.take() {
707            let selected_index = prompt.selected_suggestion;
708            // For prompts with suggestions, prefer the selected suggestion over raw input
709            let mut final_input = if prompt.sync_input_on_navigate {
710                // When sync_input_on_navigate is set, the input field is kept in sync
711                // with the selected suggestion, so always use the input value
712                prompt.input.clone()
713            } else if matches!(
714                prompt.prompt_type,
715                PromptType::OpenFile
716                    | PromptType::SwitchProject
717                    | PromptType::SaveFileAs
718                    | PromptType::StopLspServer
719                    | PromptType::RestartLspServer
720                    | PromptType::SelectTheme { .. }
721                    | PromptType::SelectLocale
722                    | PromptType::SwitchToTab
723                    | PromptType::SetLanguage
724                    | PromptType::SetEncoding
725                    | PromptType::SetLineEnding
726                    | PromptType::Plugin { .. }
727            ) {
728                // Use the selected suggestion if any
729                if let Some(selected_idx) = prompt.selected_suggestion {
730                    if let Some(suggestion) = prompt.suggestions.get(selected_idx) {
731                        // Don't confirm disabled suggestions
732                        if suggestion.disabled {
733                            self.set_status_message(
734                                t!(
735                                    "error.command_not_available",
736                                    command = suggestion.text.clone()
737                                )
738                                .to_string(),
739                            );
740                            return None;
741                        }
742                        // Use the selected suggestion value
743                        suggestion.get_value().to_string()
744                    } else {
745                        prompt.input.clone()
746                    }
747                } else {
748                    prompt.input.clone()
749                }
750            } else {
751                prompt.input.clone()
752            };
753
754            // For StopLspServer/RestartLspServer, validate that the input matches a suggestion
755            if matches!(
756                prompt.prompt_type,
757                PromptType::StopLspServer | PromptType::RestartLspServer
758            ) {
759                let is_valid = prompt
760                    .suggestions
761                    .iter()
762                    .any(|s| s.text == final_input || s.get_value() == final_input);
763                if !is_valid {
764                    // Restore the prompt and don't confirm
765                    self.prompt = Some(prompt);
766                    self.set_status_message(
767                        t!("error.no_lsp_match", input = final_input.clone()).to_string(),
768                    );
769                    return None;
770                }
771            }
772
773            // For RemoveRuler, validate input against the suggestion list.
774            // If the user typed text, it must match a suggestion value to be accepted.
775            // If the input is empty, the pre-selected suggestion is used.
776            if matches!(prompt.prompt_type, PromptType::RemoveRuler) {
777                if prompt.input.is_empty() {
778                    // No typed text — use the selected suggestion
779                    if let Some(selected_idx) = prompt.selected_suggestion {
780                        if let Some(suggestion) = prompt.suggestions.get(selected_idx) {
781                            final_input = suggestion.get_value().to_string();
782                        }
783                    } else {
784                        self.prompt = Some(prompt);
785                        return None;
786                    }
787                } else {
788                    // User typed text — it must match a suggestion value
789                    let typed = prompt.input.trim().to_string();
790                    let matched = prompt.suggestions.iter().find(|s| s.get_value() == typed);
791                    if let Some(suggestion) = matched {
792                        final_input = suggestion.get_value().to_string();
793                    } else {
794                        // Typed text doesn't match any ruler — reject
795                        self.prompt = Some(prompt);
796                        return None;
797                    }
798                }
799            }
800
801            // Add to appropriate history based on prompt type
802            if let Some(key) = Self::prompt_type_to_history_key(&prompt.prompt_type) {
803                let history = self.get_or_create_prompt_history(&key);
804                history.push(final_input.clone());
805                history.reset_navigation();
806            }
807
808            Some((final_input, prompt.prompt_type, selected_index))
809        } else {
810            None
811        }
812    }
813
814    /// Check if currently in prompt mode
815    pub fn is_prompting(&self) -> bool {
816        self.prompt.is_some()
817    }
818
819    /// Get or create a prompt history for the given key
820    pub(super) fn get_or_create_prompt_history(
821        &mut self,
822        key: &str,
823    ) -> &mut crate::input::input_history::InputHistory {
824        self.prompt_histories.entry(key.to_string()).or_default()
825    }
826
827    /// Get a prompt history for the given key (immutable)
828    pub(super) fn get_prompt_history(
829        &self,
830        key: &str,
831    ) -> Option<&crate::input::input_history::InputHistory> {
832        self.prompt_histories.get(key)
833    }
834
835    /// Get the history key for a prompt type
836    pub(super) fn prompt_type_to_history_key(
837        prompt_type: &crate::view::prompt::PromptType,
838    ) -> Option<String> {
839        use crate::view::prompt::PromptType;
840        match prompt_type {
841            PromptType::Search | PromptType::ReplaceSearch | PromptType::QueryReplaceSearch => {
842                Some("search".to_string())
843            }
844            PromptType::Replace { .. } | PromptType::QueryReplace { .. } => {
845                Some("replace".to_string())
846            }
847            PromptType::GotoLine => Some("goto_line".to_string()),
848            PromptType::Plugin { custom_type } => Some(format!("plugin:{}", custom_type)),
849            _ => None,
850        }
851    }
852
853    /// Get the current global editor mode (e.g., "vi-normal", "vi-insert")
854    /// Returns None if no special mode is active
855    pub fn editor_mode(&self) -> Option<String> {
856        self.editor_mode.clone()
857    }
858
859    /// Get access to the command registry
860    pub fn command_registry(&self) -> &Arc<RwLock<CommandRegistry>> {
861        &self.command_registry
862    }
863
864    /// Get access to the plugin manager
865    pub fn plugin_manager(&self) -> &PluginManager {
866        &self.plugin_manager
867    }
868
869    /// Get mutable access to the plugin manager
870    pub fn plugin_manager_mut(&mut self) -> &mut PluginManager {
871        &mut self.plugin_manager
872    }
873
874    /// Check if file explorer has focus
875    pub fn file_explorer_is_focused(&self) -> bool {
876        self.key_context == KeyContext::FileExplorer
877    }
878
879    /// Get current prompt input (for display)
880    pub fn prompt_input(&self) -> Option<&str> {
881        self.prompt.as_ref().map(|p| p.input.as_str())
882    }
883
884    /// Check if the active cursor currently has a selection
885    pub fn has_active_selection(&self) -> bool {
886        self.active_cursors().primary().selection_range().is_some()
887    }
888
889    /// Get mutable reference to prompt (for input handling)
890    pub fn prompt_mut(&mut self) -> Option<&mut Prompt> {
891        self.prompt.as_mut()
892    }
893
894    /// Set a status message to display in the status bar
895    pub fn set_status_message(&mut self, message: String) {
896        tracing::info!(target: "status", "{}", message);
897        self.plugin_status_message = None;
898        self.status_message = Some(message);
899    }
900
901    /// Get the current status message
902    pub fn get_status_message(&self) -> Option<&String> {
903        self.plugin_status_message
904            .as_ref()
905            .or(self.status_message.as_ref())
906    }
907
908    /// Get accumulated plugin errors (for test assertions)
909    /// Returns all error messages that were detected in plugin status messages
910    pub fn get_plugin_errors(&self) -> &[String] {
911        &self.plugin_errors
912    }
913
914    /// Clear accumulated plugin errors
915    pub fn clear_plugin_errors(&mut self) {
916        self.plugin_errors.clear();
917    }
918
919    /// Update prompt suggestions based on current input
920    pub fn update_prompt_suggestions(&mut self) {
921        // Extract prompt type and input to avoid borrow checker issues
922        let (prompt_type, input) = if let Some(prompt) = &self.prompt {
923            (prompt.prompt_type.clone(), prompt.input.clone())
924        } else {
925            return;
926        };
927
928        match prompt_type {
929            PromptType::QuickOpen => {
930                // Update Quick Open suggestions based on prefix
931                self.update_quick_open_suggestions(&input);
932            }
933            PromptType::Search | PromptType::ReplaceSearch | PromptType::QueryReplaceSearch => {
934                // Update incremental search highlights as user types
935                self.update_search_highlights(&input);
936                // Reset history navigation when user types - allows Up to navigate history
937                if let Some(history) = self.prompt_histories.get_mut("search") {
938                    history.reset_navigation();
939                }
940            }
941            PromptType::Replace { .. } | PromptType::QueryReplace { .. } => {
942                // Reset history navigation when user types - allows Up to navigate history
943                if let Some(history) = self.prompt_histories.get_mut("replace") {
944                    history.reset_navigation();
945                }
946            }
947            PromptType::GotoLine => {
948                // Reset history navigation when user types - allows Up to navigate Up arrow history
949                if let Some(history) = self.prompt_histories.get_mut("goto_line") {
950                    history.reset_navigation();
951                }
952                // Live preview for absolute line numbers only. Signed
953                // (`+N`/`-N`) inputs are relative, and previewing them as the
954                // user types each digit is disorienting — preview only on
955                // Enter for those.
956                let target = match crate::input::quick_open::parse_goto_line_input(input.trim()) {
957                    Some(crate::input::quick_open::GotoLineTarget::Absolute(n)) => Some(n),
958                    _ => None,
959                };
960                self.apply_goto_line_preview(target);
961            }
962            PromptType::OpenFile | PromptType::SwitchProject | PromptType::SaveFileAs => {
963                // For OpenFile/SwitchProject/SaveFileAs, update the file browser filter (native implementation)
964                self.update_file_open_filter();
965            }
966            PromptType::Plugin { custom_type } => {
967                // Reset history navigation when user types - allows Up to navigate history
968                let key = format!("plugin:{}", custom_type);
969                if let Some(history) = self.prompt_histories.get_mut(&key) {
970                    history.reset_navigation();
971                }
972                // Fire plugin hook for prompt input change
973                use crate::services::plugins::hooks::HookArgs;
974                self.plugin_manager.run_hook(
975                    "prompt_changed",
976                    HookArgs::PromptChanged {
977                        prompt_type: custom_type,
978                        input,
979                    },
980                );
981                // Apply fuzzy filtering if original_suggestions is set.
982                // Note: filter_suggestions checks suggestions_set_for_input to skip
983                // filtering if the plugin has already provided filtered results for
984                // this input (handles the async race condition with run_hook).
985                if let Some(prompt) = &mut self.prompt {
986                    prompt.filter_suggestions(false);
987                }
988            }
989            PromptType::SwitchToTab
990            | PromptType::SelectTheme { .. }
991            | PromptType::StopLspServer
992            | PromptType::RestartLspServer
993            | PromptType::SetLanguage
994            | PromptType::SetEncoding
995            | PromptType::SetLineEnding => {
996                if let Some(prompt) = &mut self.prompt {
997                    prompt.filter_suggestions(false);
998                }
999            }
1000            PromptType::SelectLocale => {
1001                // Locale selection also matches on description (language names)
1002                if let Some(prompt) = &mut self.prompt {
1003                    prompt.filter_suggestions(true);
1004                }
1005            }
1006            _ => {}
1007        }
1008    }
1009}