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 line N, jump there now so the user sees the target as they type
229        // (matches VSCode's Ctrl+P :<N> behavior). Otherwise, restore the
230        // cursor to its pre-preview position.
231        //
232        // Skip preview when relative_line_numbers is enabled in config, OR when
233        // input uses relative syntax (:-/:+), as preview jumps as user
234        // types each digit, which is confusing.
235        let input = input.trim();
236        let is_relative_syntax = input == ":" || input.starts_with(":-") || input.starts_with(":+");
237        let is_relative_config = self.config.editor.relative_line_numbers;
238        let target = if is_relative_syntax || is_relative_config {
239            None
240        } else {
241            Self::parse_quick_open_goto_line_target(input)
242        };
243        self.apply_goto_line_preview(target);
244    }
245
246    /// Parse a Quick Open input string for a `:<N>` goto-line target.
247    pub(super) fn parse_quick_open_goto_line_target(input: &str) -> Option<usize> {
248        input
249            .strip_prefix(':')
250            .and_then(|rest| rest.trim().parse::<usize>().ok())
251            .filter(|&n| n > 0)
252    }
253
254    /// Apply a live goto-line preview: jump to `target_line` (saving the
255    /// original cursor on the first jump) if `Some`, or restore the saved
256    /// cursor if `None`.
257    ///
258    /// Shared between Quick Open's `:N` syntax and the standalone `Goto Line`
259    /// prompt, which differ only in how the target line is parsed from input.
260    pub(super) fn apply_goto_line_preview(&mut self, target_line: Option<usize>) {
261        if let Some(line) = target_line {
262            self.save_goto_line_preview_snapshot();
263            self.goto_line_col(line, None);
264            // Record where the jump landed so restore can detect if the cursor
265            // has since moved (e.g., mouse click, external buffer edit).
266            let new_position = self.active_cursors().primary().position;
267            if let Some(snap) = self.goto_line_preview.as_mut() {
268                snap.last_jump_position = new_position;
269            }
270        } else {
271            self.restore_goto_line_preview_snapshot();
272        }
273    }
274
275    /// Save a snapshot of the active buffer's cursor and viewport so the
276    /// goto-line preview can later restore it. No-op if a snapshot is already
277    /// in place (the saved state should always be the pre-preview one).
278    pub(super) fn save_goto_line_preview_snapshot(&mut self) {
279        if self.goto_line_preview.is_some() {
280            return;
281        }
282
283        let buffer_id = self.active_buffer();
284        let split_id = self.split_manager.active_split();
285        let (cursor_id, position, anchor, sticky_column) = {
286            let cursors = self.active_cursors();
287            let primary = cursors.primary();
288            (
289                cursors.primary_id(),
290                primary.position,
291                primary.anchor,
292                primary.sticky_column,
293            )
294        };
295        let (viewport_top_byte, viewport_top_view_line_offset, viewport_left_column) = {
296            let vp = self.active_viewport();
297            (vp.top_byte, vp.top_view_line_offset, vp.left_column)
298        };
299
300        self.goto_line_preview = Some(super::GotoLinePreviewSnapshot {
301            buffer_id,
302            split_id,
303            cursor_id,
304            position,
305            anchor,
306            sticky_column,
307            viewport_top_byte,
308            viewport_top_view_line_offset,
309            viewport_left_column,
310            // Before the first jump the cursor is still at the pre-preview
311            // position; `apply_goto_line_preview` overwrites this with the
312            // jump target immediately after calling `goto_line_col`.
313            last_jump_position: position,
314        });
315    }
316
317    /// If a goto-line preview snapshot exists, restore the active split's
318    /// cursor and viewport to the saved state and clear the snapshot.
319    ///
320    /// The snapshot is only applied if the editor is still in exactly the
321    /// state the last preview jump left it in: same active buffer, same split,
322    /// cursor still at `last_jump_position`. Any deviation (user mouse-clicked,
323    /// an async edit shifted the cursor, focus moved elsewhere, …) means the
324    /// pre-preview state is stale and we simply discard the snapshot.
325    pub(super) fn restore_goto_line_preview_snapshot(&mut self) {
326        let Some(snap) = self.goto_line_preview.take() else {
327            return;
328        };
329
330        // If the active buffer/split has changed (shouldn't happen during a
331        // quick-open prompt, but be defensive), just drop the snapshot.
332        if self.active_buffer() != snap.buffer_id
333            || self.split_manager.active_split() != snap.split_id
334        {
335            return;
336        }
337
338        let cursors = self.active_cursors();
339        let current = cursors.primary();
340
341        // Cursor no longer where the preview left it → someone else moved it
342        // (mouse click, external edit via `adjust_for_edit`, …). Drop without
343        // restoring to avoid rubber-banding over that deliberate state.
344        if current.position != snap.last_jump_position {
345            return;
346        }
347        let event = crate::model::event::Event::MoveCursor {
348            cursor_id: snap.cursor_id,
349            old_position: current.position,
350            new_position: snap.position,
351            old_anchor: current.anchor,
352            new_anchor: snap.anchor,
353            old_sticky_column: current.sticky_column,
354            new_sticky_column: snap.sticky_column,
355        };
356
357        let state = self.buffers.get_mut(&snap.buffer_id).unwrap();
358        let view_state = self.split_view_states.get_mut(&snap.split_id).unwrap();
359        state.apply(&mut view_state.cursors, &event);
360
361        let vp = &mut view_state.viewport;
362        vp.top_byte = snap.viewport_top_byte;
363        vp.top_view_line_offset = snap.viewport_top_view_line_offset;
364        vp.left_column = snap.viewport_left_column;
365        // The cursor we just restored is already consistent with this
366        // viewport; don't let ensure_visible re-scroll on the next render.
367        vp.set_skip_ensure_visible();
368    }
369
370    /// Cancel search/replace prompts if one is active.
371    /// Called when focus leaves the editor (e.g., switching buffers, focusing file explorer).
372    pub(super) fn cancel_search_prompt_if_active(&mut self) {
373        if let Some(ref prompt) = self.prompt {
374            if matches!(
375                prompt.prompt_type,
376                PromptType::Search
377                    | PromptType::ReplaceSearch
378                    | PromptType::Replace { .. }
379                    | PromptType::QueryReplaceSearch
380                    | PromptType::QueryReplace { .. }
381                    | PromptType::QueryReplaceConfirm
382            ) {
383                self.prompt = None;
384                // Also cancel interactive replace if active
385                self.interactive_replace_state = None;
386                // Clear search highlights from current buffer
387                let ns = self.search_namespace.clone();
388                let state = self.active_state_mut();
389                state.overlays.clear_namespace(&ns, &mut state.marker_list);
390            }
391        }
392    }
393
394    /// Pre-fill the Open File prompt input with the current buffer directory
395    pub(super) fn prefill_open_file_prompt(&mut self) {
396        // With the native file browser, the directory is shown from file_open_state.current_dir
397        // in the prompt rendering. The prompt.input is just the filter/filename, so we
398        // start with an empty input.
399        if let Some(prompt) = self.prompt.as_mut() {
400            if prompt.prompt_type == PromptType::OpenFile {
401                prompt.input.clear();
402                prompt.cursor_pos = 0;
403                prompt.selection_anchor = None;
404            }
405        }
406    }
407
408    /// Initialize the file open dialog state
409    ///
410    /// Called when the Open File prompt is started. Determines the initial directory
411    /// (from current buffer's directory or working directory) and triggers async
412    /// directory loading.
413    pub(super) fn init_file_open_state(&mut self) {
414        // Determine initial directory
415        let buffer_id = self.active_buffer();
416
417        // For terminal buffers, use the terminal's initial CWD or fall back to project root
418        // This avoids showing the terminal backing file directory which is confusing for users
419        let initial_dir = if self.is_terminal_buffer(buffer_id) {
420            self.get_terminal_id(buffer_id)
421                .and_then(|tid| self.terminal_manager.get(tid))
422                .and_then(|handle| handle.cwd())
423                .unwrap_or_else(|| self.working_dir.clone())
424        } else {
425            self.active_state()
426                .buffer
427                .file_path()
428                .and_then(|path| path.parent())
429                .map(|p| p.to_path_buf())
430                .unwrap_or_else(|| self.working_dir.clone())
431        };
432
433        // Create the file open state with config-based show_hidden setting
434        let show_hidden = self.config.file_browser.show_hidden;
435        self.file_open_state = Some(file_open::FileOpenState::new(
436            initial_dir.clone(),
437            show_hidden,
438            self.authority.filesystem.clone(),
439        ));
440
441        // Start async directory loading and async shortcuts loading in parallel
442        self.load_file_open_directory(initial_dir);
443        self.load_file_open_shortcuts_async();
444    }
445
446    /// Initialize the folder open dialog state
447    ///
448    /// Called when the Switch Project prompt is started. Starts from the current working
449    /// directory and triggers async directory loading.
450    pub(super) fn init_folder_open_state(&mut self) {
451        // Start from the current working directory
452        let initial_dir = self.working_dir.clone();
453
454        // Create the file open state with config-based show_hidden setting
455        let show_hidden = self.config.file_browser.show_hidden;
456        self.file_open_state = Some(file_open::FileOpenState::new(
457            initial_dir.clone(),
458            show_hidden,
459            self.authority.filesystem.clone(),
460        ));
461
462        // Start async directory loading and async shortcuts loading in parallel
463        self.load_file_open_directory(initial_dir);
464        self.load_file_open_shortcuts_async();
465    }
466
467    /// Change the working directory to a new path
468    ///
469    /// This requests a full editor restart with the new working directory.
470    /// The main loop will drop the current editor instance and create a fresh
471    /// one pointing to the new directory. This ensures:
472    /// - All buffers are cleanly closed
473    /// - LSP servers are properly shut down and restarted with new root
474    /// - Plugins are cleanly restarted
475    /// - No state leaks between projects
476    pub fn change_working_dir(&mut self, new_path: PathBuf) {
477        // Canonicalize the path to resolve symlinks and normalize
478        let new_path = new_path.canonicalize().unwrap_or(new_path);
479
480        // Request a restart with the new working directory
481        // The main loop will handle creating a fresh editor instance
482        self.request_restart(new_path);
483    }
484
485    /// Load directory contents for the file open dialog
486    pub(super) fn load_file_open_directory(&mut self, path: PathBuf) {
487        // Update state to loading
488        if let Some(state) = &mut self.file_open_state {
489            state.current_dir = path.clone();
490            state.loading = true;
491            state.error = None;
492            state.update_shortcuts();
493        }
494
495        // Use tokio runtime to load directory
496        if let Some(ref runtime) = self.tokio_runtime {
497            let fs_manager = self.fs_manager.clone();
498            let sender = self.async_bridge.as_ref().map(|b| b.sender());
499
500            runtime.spawn(async move {
501                let result = fs_manager.list_dir_with_metadata(path).await;
502                if let Some(sender) = sender {
503                    // Receiver may have been dropped if the dialog was closed.
504                    #[allow(clippy::let_underscore_must_use)]
505                    let _ = sender.send(AsyncMessage::FileOpenDirectoryLoaded(result));
506                }
507            });
508        } else {
509            // No runtime, set error
510            if let Some(state) = &mut self.file_open_state {
511                state.set_error("Async runtime not available".to_string());
512            }
513        }
514    }
515
516    /// Handle file open directory load result
517    pub(super) fn handle_file_open_directory_loaded(
518        &mut self,
519        result: std::io::Result<Vec<crate::services::fs::DirEntry>>,
520    ) {
521        match result {
522            Ok(entries) => {
523                if let Some(state) = &mut self.file_open_state {
524                    state.set_entries(entries);
525                }
526                // Re-apply filter from prompt (entries were just loaded, filter needs to select matching entry)
527                let filter = self
528                    .prompt
529                    .as_ref()
530                    .map(|p| p.input.clone())
531                    .unwrap_or_default();
532                if !filter.is_empty() {
533                    if let Some(state) = &mut self.file_open_state {
534                        state.apply_filter(&filter);
535                    }
536                }
537            }
538            Err(e) => {
539                if let Some(state) = &mut self.file_open_state {
540                    state.set_error(e.to_string());
541                }
542            }
543        }
544    }
545
546    /// Load async shortcuts (documents, downloads, Windows drive letters) in the background.
547    /// This prevents the UI from hanging when checking paths that may be slow or unreachable.
548    /// See issue #903.
549    pub(super) fn load_file_open_shortcuts_async(&mut self) {
550        if let Some(ref runtime) = self.tokio_runtime {
551            let filesystem = self.authority.filesystem.clone();
552            let sender = self.async_bridge.as_ref().map(|b| b.sender());
553
554            runtime.spawn(async move {
555                // Run the blocking filesystem checks in a separate thread
556                let shortcuts = tokio::task::spawn_blocking(move || {
557                    file_open::FileOpenState::build_shortcuts_async(&*filesystem)
558                })
559                .await
560                .unwrap_or_default();
561
562                if let Some(sender) = sender {
563                    // Receiver may have been dropped if the dialog was closed.
564                    #[allow(clippy::let_underscore_must_use)]
565                    let _ = sender.send(AsyncMessage::FileOpenShortcutsLoaded(shortcuts));
566                }
567            });
568        }
569    }
570
571    /// Handle async shortcuts load result
572    pub(super) fn handle_file_open_shortcuts_loaded(
573        &mut self,
574        shortcuts: Vec<file_open::NavigationShortcut>,
575    ) {
576        if let Some(state) = &mut self.file_open_state {
577            state.merge_async_shortcuts(shortcuts);
578        }
579    }
580
581    /// Cancel the current prompt and return to normal mode
582    pub fn cancel_prompt(&mut self) {
583        // Extract theme to restore if this is a SelectTheme prompt
584        let theme_to_restore = if let Some(ref prompt) = self.prompt {
585            if let PromptType::SelectTheme { original_theme } = &prompt.prompt_type {
586                Some(original_theme.clone())
587            } else {
588                None
589            }
590        } else {
591            None
592        };
593
594        // Determine prompt type and reset appropriate history navigation
595        if let Some(ref prompt) = self.prompt {
596            // Reset history navigation for this prompt type
597            if let Some(key) = Self::prompt_type_to_history_key(&prompt.prompt_type) {
598                if let Some(history) = self.prompt_histories.get_mut(&key) {
599                    history.reset_navigation();
600                }
601            }
602            match &prompt.prompt_type {
603                PromptType::Search | PromptType::ReplaceSearch | PromptType::QueryReplaceSearch => {
604                    self.clear_search_highlights();
605                }
606                PromptType::Plugin { custom_type } => {
607                    // Fire plugin hook for prompt cancellation
608                    use crate::services::plugins::hooks::HookArgs;
609                    self.plugin_manager.run_hook(
610                        "prompt_cancelled",
611                        HookArgs::PromptCancelled {
612                            prompt_type: custom_type.clone(),
613                            input: prompt.input.clone(),
614                        },
615                    );
616                }
617                PromptType::LspRename { overlay_handle, .. } => {
618                    // Remove the rename overlay when cancelling
619                    let remove_overlay_event = crate::model::event::Event::RemoveOverlay {
620                        handle: overlay_handle.clone(),
621                    };
622                    self.apply_event_to_active_buffer(&remove_overlay_event);
623                }
624                PromptType::OpenFile | PromptType::SwitchProject | PromptType::SaveFileAs => {
625                    // Clear file browser state
626                    self.file_open_state = None;
627                    self.file_browser_layout = None;
628                }
629                PromptType::AsyncPrompt => {
630                    // Resolve the pending async prompt callback with null (cancelled)
631                    if let Some(callback_id) = self.pending_async_prompt_callback.take() {
632                        self.plugin_manager
633                            .resolve_callback(callback_id, "null".to_string());
634                    }
635                }
636                PromptType::QuickOpen => {
637                    // Cancel any in-progress background file loading
638                    if let Some((provider, _)) = self.quick_open_registry.get_provider_for_input("")
639                    {
640                        if let Some(fp) = provider
641                            .as_any()
642                            .downcast_ref::<crate::input::quick_open::providers::FileProvider>(
643                        ) {
644                            fp.cancel_loading();
645                        }
646                    }
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                PromptType::GotoLine => {
652                    // Undo any live goto-line preview so the cursor returns to
653                    // where it was before the prompt was opened.
654                    self.restore_goto_line_preview_snapshot();
655                }
656                _ => {}
657            }
658        }
659
660        self.prompt = None;
661        self.pending_search_range = None;
662        self.status_message = Some(t!("search.cancelled").to_string());
663
664        // Restore original theme if we were in SelectTheme prompt
665        if let Some(original_theme) = theme_to_restore {
666            self.preview_theme(&original_theme);
667        }
668    }
669
670    /// Handle mouse wheel scroll in prompt with suggestions.
671    /// Returns true if scroll was handled, false if no prompt is active or has no suggestions.
672    pub fn handle_prompt_scroll(&mut self, delta: i32) -> bool {
673        if let Some(ref mut prompt) = self.prompt {
674            if prompt.suggestions.is_empty() {
675                return false;
676            }
677
678            let current = prompt.selected_suggestion.unwrap_or(0);
679            let len = prompt.suggestions.len();
680
681            // Calculate new position based on scroll direction
682            // delta < 0 = scroll up, delta > 0 = scroll down
683            let new_selected = if delta < 0 {
684                // Scroll up - move selection up (decrease index)
685                current.saturating_sub((-delta) as usize)
686            } else {
687                // Scroll down - move selection down (increase index)
688                (current + delta as usize).min(len.saturating_sub(1))
689            };
690
691            prompt.selected_suggestion = Some(new_selected);
692
693            // Update input to match selected suggestion for non-plugin prompts
694            if !matches!(prompt.prompt_type, PromptType::Plugin { .. }) {
695                if let Some(suggestion) = prompt.suggestions.get(new_selected) {
696                    prompt.input = suggestion.get_value().to_string();
697                    prompt.cursor_pos = prompt.input.len();
698                }
699            }
700
701            return true;
702        }
703        false
704    }
705
706    /// Get the confirmed input and prompt type, consuming the prompt
707    /// For command palette, returns the selected suggestion if available, otherwise the raw input
708    /// Returns (input, prompt_type, selected_index)
709    /// Returns None if trying to confirm a disabled command
710    pub fn confirm_prompt(&mut self) -> Option<(String, PromptType, Option<usize>)> {
711        if let Some(prompt) = self.prompt.take() {
712            let selected_index = prompt.selected_suggestion;
713            // For prompts with suggestions, prefer the selected suggestion over raw input
714            let mut final_input = if prompt.sync_input_on_navigate {
715                // When sync_input_on_navigate is set, the input field is kept in sync
716                // with the selected suggestion, so always use the input value
717                prompt.input.clone()
718            } else if matches!(
719                prompt.prompt_type,
720                PromptType::OpenFile
721                    | PromptType::SwitchProject
722                    | PromptType::SaveFileAs
723                    | PromptType::StopLspServer
724                    | PromptType::RestartLspServer
725                    | PromptType::SelectTheme { .. }
726                    | PromptType::SelectLocale
727                    | PromptType::SwitchToTab
728                    | PromptType::SetLanguage
729                    | PromptType::SetEncoding
730                    | PromptType::SetLineEnding
731                    | PromptType::Plugin { .. }
732            ) {
733                // Use the selected suggestion if any
734                if let Some(selected_idx) = prompt.selected_suggestion {
735                    if let Some(suggestion) = prompt.suggestions.get(selected_idx) {
736                        // Don't confirm disabled suggestions
737                        if suggestion.disabled {
738                            self.set_status_message(
739                                t!(
740                                    "error.command_not_available",
741                                    command = suggestion.text.clone()
742                                )
743                                .to_string(),
744                            );
745                            return None;
746                        }
747                        // Use the selected suggestion value
748                        suggestion.get_value().to_string()
749                    } else {
750                        prompt.input.clone()
751                    }
752                } else {
753                    prompt.input.clone()
754                }
755            } else {
756                prompt.input.clone()
757            };
758
759            // For StopLspServer/RestartLspServer, validate that the input matches a suggestion
760            if matches!(
761                prompt.prompt_type,
762                PromptType::StopLspServer | PromptType::RestartLspServer
763            ) {
764                let is_valid = prompt
765                    .suggestions
766                    .iter()
767                    .any(|s| s.text == final_input || s.get_value() == final_input);
768                if !is_valid {
769                    // Restore the prompt and don't confirm
770                    self.prompt = Some(prompt);
771                    self.set_status_message(
772                        t!("error.no_lsp_match", input = final_input.clone()).to_string(),
773                    );
774                    return None;
775                }
776            }
777
778            // For RemoveRuler, validate input against the suggestion list.
779            // If the user typed text, it must match a suggestion value to be accepted.
780            // If the input is empty, the pre-selected suggestion is used.
781            if matches!(prompt.prompt_type, PromptType::RemoveRuler) {
782                if prompt.input.is_empty() {
783                    // No typed text — use the selected suggestion
784                    if let Some(selected_idx) = prompt.selected_suggestion {
785                        if let Some(suggestion) = prompt.suggestions.get(selected_idx) {
786                            final_input = suggestion.get_value().to_string();
787                        }
788                    } else {
789                        self.prompt = Some(prompt);
790                        return None;
791                    }
792                } else {
793                    // User typed text — it must match a suggestion value
794                    let typed = prompt.input.trim().to_string();
795                    let matched = prompt.suggestions.iter().find(|s| s.get_value() == typed);
796                    if let Some(suggestion) = matched {
797                        final_input = suggestion.get_value().to_string();
798                    } else {
799                        // Typed text doesn't match any ruler — reject
800                        self.prompt = Some(prompt);
801                        return None;
802                    }
803                }
804            }
805
806            // Add to appropriate history based on prompt type
807            if let Some(key) = Self::prompt_type_to_history_key(&prompt.prompt_type) {
808                let history = self.get_or_create_prompt_history(&key);
809                history.push(final_input.clone());
810                history.reset_navigation();
811            }
812
813            Some((final_input, prompt.prompt_type, selected_index))
814        } else {
815            None
816        }
817    }
818
819    /// Check if currently in prompt mode
820    pub fn is_prompting(&self) -> bool {
821        self.prompt.is_some()
822    }
823
824    /// Get or create a prompt history for the given key
825    pub(super) fn get_or_create_prompt_history(
826        &mut self,
827        key: &str,
828    ) -> &mut crate::input::input_history::InputHistory {
829        self.prompt_histories.entry(key.to_string()).or_default()
830    }
831
832    /// Get a prompt history for the given key (immutable)
833    pub(super) fn get_prompt_history(
834        &self,
835        key: &str,
836    ) -> Option<&crate::input::input_history::InputHistory> {
837        self.prompt_histories.get(key)
838    }
839
840    /// Get the history key for a prompt type
841    pub(super) fn prompt_type_to_history_key(
842        prompt_type: &crate::view::prompt::PromptType,
843    ) -> Option<String> {
844        use crate::view::prompt::PromptType;
845        match prompt_type {
846            PromptType::Search | PromptType::ReplaceSearch | PromptType::QueryReplaceSearch => {
847                Some("search".to_string())
848            }
849            PromptType::Replace { .. } | PromptType::QueryReplace { .. } => {
850                Some("replace".to_string())
851            }
852            PromptType::GotoLine => Some("goto_line".to_string()),
853            PromptType::Plugin { custom_type } => Some(format!("plugin:{}", custom_type)),
854            _ => None,
855        }
856    }
857
858    /// Get the current global editor mode (e.g., "vi-normal", "vi-insert")
859    /// Returns None if no special mode is active
860    pub fn editor_mode(&self) -> Option<String> {
861        self.editor_mode.clone()
862    }
863
864    /// Get access to the command registry
865    pub fn command_registry(&self) -> &Arc<RwLock<CommandRegistry>> {
866        &self.command_registry
867    }
868
869    /// Get access to the plugin manager
870    pub fn plugin_manager(&self) -> &PluginManager {
871        &self.plugin_manager
872    }
873
874    /// Get mutable access to the plugin manager
875    pub fn plugin_manager_mut(&mut self) -> &mut PluginManager {
876        &mut self.plugin_manager
877    }
878
879    /// Check if file explorer has focus
880    pub fn file_explorer_is_focused(&self) -> bool {
881        self.key_context == KeyContext::FileExplorer
882    }
883
884    /// Get current prompt input (for display)
885    pub fn prompt_input(&self) -> Option<&str> {
886        self.prompt.as_ref().map(|p| p.input.as_str())
887    }
888
889    /// Check if the active cursor currently has a selection
890    pub fn has_active_selection(&self) -> bool {
891        self.active_cursors().primary().selection_range().is_some()
892    }
893
894    /// Get mutable reference to prompt (for input handling)
895    pub fn prompt_mut(&mut self) -> Option<&mut Prompt> {
896        self.prompt.as_mut()
897    }
898
899    /// Set a status message to display in the status bar
900    pub fn set_status_message(&mut self, message: String) {
901        tracing::info!(target: "status", "{}", message);
902        self.plugin_status_message = None;
903        self.status_message = Some(message);
904    }
905
906    /// Get the current status message
907    pub fn get_status_message(&self) -> Option<&String> {
908        self.plugin_status_message
909            .as_ref()
910            .or(self.status_message.as_ref())
911    }
912
913    /// Get accumulated plugin errors (for test assertions)
914    /// Returns all error messages that were detected in plugin status messages
915    pub fn get_plugin_errors(&self) -> &[String] {
916        &self.plugin_errors
917    }
918
919    /// Clear accumulated plugin errors
920    pub fn clear_plugin_errors(&mut self) {
921        self.plugin_errors.clear();
922    }
923
924    /// Update prompt suggestions based on current input
925    pub fn update_prompt_suggestions(&mut self) {
926        // Extract prompt type and input to avoid borrow checker issues
927        let (prompt_type, input) = if let Some(prompt) = &self.prompt {
928            (prompt.prompt_type.clone(), prompt.input.clone())
929        } else {
930            return;
931        };
932
933        match prompt_type {
934            PromptType::QuickOpen => {
935                // Update Quick Open suggestions based on prefix
936                self.update_quick_open_suggestions(&input);
937            }
938            PromptType::Search | PromptType::ReplaceSearch | PromptType::QueryReplaceSearch => {
939                // Update incremental search highlights as user types
940                self.update_search_highlights(&input);
941                // Reset history navigation when user types - allows Up to navigate history
942                if let Some(history) = self.prompt_histories.get_mut("search") {
943                    history.reset_navigation();
944                }
945            }
946            PromptType::Replace { .. } | PromptType::QueryReplace { .. } => {
947                // Reset history navigation when user types - allows Up to navigate history
948                if let Some(history) = self.prompt_histories.get_mut("replace") {
949                    history.reset_navigation();
950                }
951            }
952            PromptType::GotoLine => {
953                // Reset history navigation when user types - allows Up to navigate Up arrow history
954                if let Some(history) = self.prompt_histories.get_mut("goto_line") {
955                    history.reset_navigation();
956                }
957                // Live preview for absolute line numbers only. For relative numbers,
958                // preview jumps as user types each digit (confusing),
959                // so skip preview and jump on Enter only.
960                let input = input.trim();
961                let target = if self.config.editor.relative_line_numbers {
962                    None
963                } else {
964                    input.parse::<usize>().ok().filter(|&n| n > 0)
965                };
966                self.apply_goto_line_preview(target);
967            }
968            PromptType::OpenFile | PromptType::SwitchProject | PromptType::SaveFileAs => {
969                // For OpenFile/SwitchProject/SaveFileAs, update the file browser filter (native implementation)
970                self.update_file_open_filter();
971            }
972            PromptType::Plugin { custom_type } => {
973                // Reset history navigation when user types - allows Up to navigate history
974                let key = format!("plugin:{}", custom_type);
975                if let Some(history) = self.prompt_histories.get_mut(&key) {
976                    history.reset_navigation();
977                }
978                // Fire plugin hook for prompt input change
979                use crate::services::plugins::hooks::HookArgs;
980                self.plugin_manager.run_hook(
981                    "prompt_changed",
982                    HookArgs::PromptChanged {
983                        prompt_type: custom_type,
984                        input,
985                    },
986                );
987                // Apply fuzzy filtering if original_suggestions is set.
988                // Note: filter_suggestions checks suggestions_set_for_input to skip
989                // filtering if the plugin has already provided filtered results for
990                // this input (handles the async race condition with run_hook).
991                if let Some(prompt) = &mut self.prompt {
992                    prompt.filter_suggestions(false);
993                }
994            }
995            PromptType::SwitchToTab
996            | PromptType::SelectTheme { .. }
997            | PromptType::StopLspServer
998            | PromptType::RestartLspServer
999            | PromptType::SetLanguage
1000            | PromptType::SetEncoding
1001            | PromptType::SetLineEnding => {
1002                if let Some(prompt) = &mut self.prompt {
1003                    prompt.filter_suggestions(false);
1004                }
1005            }
1006            PromptType::SelectLocale => {
1007                // Locale selection also matches on description (language names)
1008                if let Some(prompt) = &mut self.prompt {
1009                    prompt.filter_suggestions(true);
1010                }
1011            }
1012            _ => {}
1013        }
1014    }
1015}