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 anyhow::Result as AnyhowResult;
11use rust_i18n::t;
12
13use crate::input::command_registry::CommandRegistry;
14use crate::input::commands::Suggestion;
15use crate::input::input_history::InputHistory;
16use crate::input::keybindings::KeyContext;
17use crate::input::quick_open::{BufferInfo, QuickOpenContext};
18use crate::services::async_bridge::AsyncMessage;
19use crate::services::plugins::PluginManager;
20use crate::view::prompt::{Prompt, PromptType};
21
22use super::file_open;
23use super::Editor;
24
25impl Editor {
26    // Prompt/Minibuffer control methods
27
28    /// Start a new prompt (enter minibuffer mode)
29    pub fn start_prompt(&mut self, message: String, prompt_type: PromptType) {
30        self.start_prompt_with_suggestions(message, prompt_type, Vec::new());
31    }
32
33    /// Start a search prompt with an optional selection scope
34    ///
35    /// When `use_selection_range` is true and a single-line selection is present,
36    /// the search will be restricted to that range once confirmed.
37    pub(super) fn start_search_prompt(
38        &mut self,
39        message: String,
40        prompt_type: PromptType,
41        use_selection_range: bool,
42    ) {
43        // Reset any previously stored selection range
44        self.pending_search_range = None;
45
46        let selection_range = self.active_cursors().primary().selection_range();
47
48        let selected_text = if let Some(range) = selection_range.clone() {
49            let state = self.active_state_mut();
50            let text = state.get_text_range(range.start, range.end);
51            if !text.contains('\n') && !text.is_empty() {
52                Some(text)
53            } else {
54                None
55            }
56        } else {
57            None
58        };
59
60        if use_selection_range {
61            self.pending_search_range = selection_range;
62        }
63
64        // Determine the default text: selection > last history > empty
65        let from_history = selected_text.is_none();
66        let default_text = selected_text.or_else(|| {
67            self.get_prompt_history("search")
68                .and_then(|h| h.last().map(|s| s.to_string()))
69        });
70
71        // Start the prompt
72        self.start_prompt(message, prompt_type);
73
74        // Pre-fill with default text if available
75        if let Some(text) = default_text {
76            if let Some(ref mut prompt) = self.prompt {
77                prompt.set_input(text.clone());
78                prompt.selection_anchor = Some(0);
79                prompt.cursor_pos = text.len();
80            }
81            if from_history {
82                self.get_or_create_prompt_history("search").init_at_last();
83            }
84            self.update_search_highlights(&text);
85        }
86    }
87
88    /// Start a new prompt with autocomplete suggestions
89    pub fn start_prompt_with_suggestions(
90        &mut self,
91        message: String,
92        prompt_type: PromptType,
93        suggestions: Vec<Suggestion>,
94    ) {
95        // Dismiss transient popups and clear hover state when opening a prompt
96        self.on_editor_focus_lost();
97
98        // Clear search highlights when starting a new search prompt
99        // This ensures old highlights from previous searches don't persist
100        match prompt_type {
101            PromptType::Search | PromptType::ReplaceSearch | PromptType::QueryReplaceSearch => {
102                self.clear_search_highlights();
103            }
104            _ => {}
105        }
106
107        // Check if we need to update suggestions after creating the prompt
108        let needs_suggestions = matches!(
109            prompt_type,
110            PromptType::OpenFile | PromptType::SwitchProject | PromptType::SaveFileAs
111        );
112
113        self.prompt = Some(Prompt::with_suggestions(message, prompt_type, suggestions));
114
115        // For file and command prompts, populate initial suggestions
116        if needs_suggestions {
117            self.update_prompt_suggestions();
118        }
119    }
120
121    /// Start a new prompt with initial text
122    pub fn start_prompt_with_initial_text(
123        &mut self,
124        message: String,
125        prompt_type: PromptType,
126        initial_text: String,
127    ) {
128        // Dismiss transient popups and clear hover state when opening a prompt
129        self.on_editor_focus_lost();
130
131        self.prompt = Some(Prompt::with_initial_text(
132            message,
133            prompt_type,
134            initial_text,
135        ));
136    }
137
138    /// Start Quick Open prompt with command palette as default
139    pub fn start_quick_open(&mut self) {
140        // Dismiss transient popups and clear hover state
141        self.on_editor_focus_lost();
142
143        // Clear status message since hints are now shown in the popup
144        self.status_message = None;
145
146        // Start with ">" prefix for command mode by default
147        let mut prompt = Prompt::with_suggestions(String::new(), PromptType::QuickOpen, vec![]);
148        prompt.input = ">".to_string();
149        prompt.cursor_pos = 1;
150        self.prompt = Some(prompt);
151
152        // Load initial command suggestions
153        self.update_quick_open_suggestions(">");
154    }
155
156    /// Build a QuickOpenContext from current editor state
157    pub(super) fn build_quick_open_context(&self) -> QuickOpenContext {
158        let open_buffers = self
159            .buffers
160            .iter()
161            .filter_map(|(buffer_id, state)| {
162                let path = state.buffer.file_path()?;
163                let name = path
164                    .file_name()
165                    .map(|n| n.to_string_lossy().to_string())
166                    .unwrap_or_else(|| format!("Buffer {}", buffer_id.0));
167                Some(BufferInfo {
168                    id: buffer_id.0,
169                    path: path.display().to_string(),
170                    name,
171                    modified: state.buffer.is_modified(),
172                })
173            })
174            .collect();
175
176        let has_lsp_config = {
177            let language = self
178                .buffers
179                .get(&self.active_buffer())
180                .map(|s| s.language.as_str());
181            language
182                .and_then(|lang| self.lsp.as_ref().and_then(|lsp| lsp.get_config(lang)))
183                .is_some()
184        };
185
186        QuickOpenContext {
187            cwd: self.working_dir.display().to_string(),
188            open_buffers,
189            active_buffer_id: self.active_buffer().0,
190            active_buffer_path: self
191                .active_state()
192                .buffer
193                .file_path()
194                .map(|p| p.display().to_string()),
195            has_selection: self.has_active_selection(),
196            key_context: self.key_context.clone(),
197            custom_contexts: self.active_custom_contexts.clone(),
198            buffer_mode: self
199                .buffer_metadata
200                .get(&self.active_buffer())
201                .and_then(|m| m.virtual_mode())
202                .map(|s| s.to_string()),
203            has_lsp_config,
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
228    /// Cancel search/replace prompts if one is active.
229    /// Called when focus leaves the editor (e.g., switching buffers, focusing file explorer).
230    pub(super) fn cancel_search_prompt_if_active(&mut self) {
231        if let Some(ref prompt) = self.prompt {
232            if matches!(
233                prompt.prompt_type,
234                PromptType::Search
235                    | PromptType::ReplaceSearch
236                    | PromptType::Replace { .. }
237                    | PromptType::QueryReplaceSearch
238                    | PromptType::QueryReplace { .. }
239                    | PromptType::QueryReplaceConfirm
240            ) {
241                self.prompt = None;
242                // Also cancel interactive replace if active
243                self.interactive_replace_state = None;
244                // Clear search highlights from current buffer
245                let ns = self.search_namespace.clone();
246                let state = self.active_state_mut();
247                state.overlays.clear_namespace(&ns, &mut state.marker_list);
248            }
249        }
250    }
251
252    /// Pre-fill the Open File prompt input with the current buffer directory
253    pub(super) fn prefill_open_file_prompt(&mut self) {
254        // With the native file browser, the directory is shown from file_open_state.current_dir
255        // in the prompt rendering. The prompt.input is just the filter/filename, so we
256        // start with an empty input.
257        if let Some(prompt) = self.prompt.as_mut() {
258            if prompt.prompt_type == PromptType::OpenFile {
259                prompt.input.clear();
260                prompt.cursor_pos = 0;
261                prompt.selection_anchor = None;
262            }
263        }
264    }
265
266    /// Initialize the file open dialog state
267    ///
268    /// Called when the Open File prompt is started. Determines the initial directory
269    /// (from current buffer's directory or working directory) and triggers async
270    /// directory loading.
271    pub(super) fn init_file_open_state(&mut self) {
272        // Determine initial directory
273        let buffer_id = self.active_buffer();
274
275        // For terminal buffers, use the terminal's initial CWD or fall back to project root
276        // This avoids showing the terminal backing file directory which is confusing for users
277        let initial_dir = if self.is_terminal_buffer(buffer_id) {
278            self.get_terminal_id(buffer_id)
279                .and_then(|tid| self.terminal_manager.get(tid))
280                .and_then(|handle| handle.cwd())
281                .unwrap_or_else(|| self.working_dir.clone())
282        } else {
283            self.active_state()
284                .buffer
285                .file_path()
286                .and_then(|path| path.parent())
287                .map(|p| p.to_path_buf())
288                .unwrap_or_else(|| self.working_dir.clone())
289        };
290
291        // Create the file open state with config-based show_hidden setting
292        let show_hidden = self.config.file_browser.show_hidden;
293        self.file_open_state = Some(file_open::FileOpenState::new(
294            initial_dir.clone(),
295            show_hidden,
296            self.filesystem.clone(),
297        ));
298
299        // Start async directory loading and async shortcuts loading in parallel
300        self.load_file_open_directory(initial_dir);
301        self.load_file_open_shortcuts_async();
302    }
303
304    /// Initialize the folder open dialog state
305    ///
306    /// Called when the Switch Project prompt is started. Starts from the current working
307    /// directory and triggers async directory loading.
308    pub(super) fn init_folder_open_state(&mut self) {
309        // Start from the current working directory
310        let initial_dir = self.working_dir.clone();
311
312        // Create the file open state with config-based show_hidden setting
313        let show_hidden = self.config.file_browser.show_hidden;
314        self.file_open_state = Some(file_open::FileOpenState::new(
315            initial_dir.clone(),
316            show_hidden,
317            self.filesystem.clone(),
318        ));
319
320        // Start async directory loading and async shortcuts loading in parallel
321        self.load_file_open_directory(initial_dir);
322        self.load_file_open_shortcuts_async();
323    }
324
325    /// Change the working directory to a new path
326    ///
327    /// This requests a full editor restart with the new working directory.
328    /// The main loop will drop the current editor instance and create a fresh
329    /// one pointing to the new directory. This ensures:
330    /// - All buffers are cleanly closed
331    /// - LSP servers are properly shut down and restarted with new root
332    /// - Plugins are cleanly restarted
333    /// - No state leaks between projects
334    pub fn change_working_dir(&mut self, new_path: PathBuf) {
335        // Canonicalize the path to resolve symlinks and normalize
336        let new_path = new_path.canonicalize().unwrap_or(new_path);
337
338        // Request a restart with the new working directory
339        // The main loop will handle creating a fresh editor instance
340        self.request_restart(new_path);
341    }
342
343    /// Load directory contents for the file open dialog
344    pub(super) fn load_file_open_directory(&mut self, path: PathBuf) {
345        // Update state to loading
346        if let Some(state) = &mut self.file_open_state {
347            state.current_dir = path.clone();
348            state.loading = true;
349            state.error = None;
350            state.update_shortcuts();
351        }
352
353        // Use tokio runtime to load directory
354        if let Some(ref runtime) = self.tokio_runtime {
355            let fs_manager = self.fs_manager.clone();
356            let sender = self.async_bridge.as_ref().map(|b| b.sender());
357
358            runtime.spawn(async move {
359                let result = fs_manager.list_dir_with_metadata(path).await;
360                if let Some(sender) = sender {
361                    // Receiver may have been dropped if the dialog was closed.
362                    #[allow(clippy::let_underscore_must_use)]
363                    let _ = sender.send(AsyncMessage::FileOpenDirectoryLoaded(result));
364                }
365            });
366        } else {
367            // No runtime, set error
368            if let Some(state) = &mut self.file_open_state {
369                state.set_error("Async runtime not available".to_string());
370            }
371        }
372    }
373
374    /// Handle file open directory load result
375    pub(super) fn handle_file_open_directory_loaded(
376        &mut self,
377        result: std::io::Result<Vec<crate::services::fs::DirEntry>>,
378    ) {
379        match result {
380            Ok(entries) => {
381                if let Some(state) = &mut self.file_open_state {
382                    state.set_entries(entries);
383                }
384                // Re-apply filter from prompt (entries were just loaded, filter needs to select matching entry)
385                let filter = self
386                    .prompt
387                    .as_ref()
388                    .map(|p| p.input.clone())
389                    .unwrap_or_default();
390                if !filter.is_empty() {
391                    if let Some(state) = &mut self.file_open_state {
392                        state.apply_filter(&filter);
393                    }
394                }
395            }
396            Err(e) => {
397                if let Some(state) = &mut self.file_open_state {
398                    state.set_error(e.to_string());
399                }
400            }
401        }
402    }
403
404    /// Load async shortcuts (documents, downloads, Windows drive letters) in the background.
405    /// This prevents the UI from hanging when checking paths that may be slow or unreachable.
406    /// See issue #903.
407    pub(super) fn load_file_open_shortcuts_async(&mut self) {
408        if let Some(ref runtime) = self.tokio_runtime {
409            let filesystem = self.filesystem.clone();
410            let sender = self.async_bridge.as_ref().map(|b| b.sender());
411
412            runtime.spawn(async move {
413                // Run the blocking filesystem checks in a separate thread
414                let shortcuts = tokio::task::spawn_blocking(move || {
415                    file_open::FileOpenState::build_shortcuts_async(&*filesystem)
416                })
417                .await
418                .unwrap_or_default();
419
420                if let Some(sender) = sender {
421                    // Receiver may have been dropped if the dialog was closed.
422                    #[allow(clippy::let_underscore_must_use)]
423                    let _ = sender.send(AsyncMessage::FileOpenShortcutsLoaded(shortcuts));
424                }
425            });
426        }
427    }
428
429    /// Handle async shortcuts load result
430    pub(super) fn handle_file_open_shortcuts_loaded(
431        &mut self,
432        shortcuts: Vec<file_open::NavigationShortcut>,
433    ) {
434        if let Some(state) = &mut self.file_open_state {
435            state.merge_async_shortcuts(shortcuts);
436        }
437    }
438
439    /// Cancel the current prompt and return to normal mode
440    pub fn cancel_prompt(&mut self) {
441        // Extract theme to restore if this is a SelectTheme prompt
442        let theme_to_restore = if let Some(ref prompt) = self.prompt {
443            if let PromptType::SelectTheme { original_theme } = &prompt.prompt_type {
444                Some(original_theme.clone())
445            } else {
446                None
447            }
448        } else {
449            None
450        };
451
452        // Determine prompt type and reset appropriate history navigation
453        if let Some(ref prompt) = self.prompt {
454            // Reset history navigation for this prompt type
455            if let Some(key) = Self::prompt_type_to_history_key(&prompt.prompt_type) {
456                if let Some(history) = self.prompt_histories.get_mut(&key) {
457                    history.reset_navigation();
458                }
459            }
460            match &prompt.prompt_type {
461                PromptType::Search | PromptType::ReplaceSearch | PromptType::QueryReplaceSearch => {
462                    self.clear_search_highlights();
463                }
464                PromptType::Plugin { custom_type } => {
465                    // Fire plugin hook for prompt cancellation
466                    use crate::services::plugins::hooks::HookArgs;
467                    self.plugin_manager.run_hook(
468                        "prompt_cancelled",
469                        HookArgs::PromptCancelled {
470                            prompt_type: custom_type.clone(),
471                            input: prompt.input.clone(),
472                        },
473                    );
474                }
475                PromptType::LspRename { overlay_handle, .. } => {
476                    // Remove the rename overlay when cancelling
477                    let remove_overlay_event = crate::model::event::Event::RemoveOverlay {
478                        handle: overlay_handle.clone(),
479                    };
480                    self.apply_event_to_active_buffer(&remove_overlay_event);
481                }
482                PromptType::OpenFile | PromptType::SwitchProject | PromptType::SaveFileAs => {
483                    // Clear file browser state
484                    self.file_open_state = None;
485                    self.file_browser_layout = None;
486                }
487                PromptType::AsyncPrompt => {
488                    // Resolve the pending async prompt callback with null (cancelled)
489                    if let Some(callback_id) = self.pending_async_prompt_callback.take() {
490                        self.plugin_manager
491                            .resolve_callback(callback_id, "null".to_string());
492                    }
493                }
494                PromptType::QuickOpen => {
495                    // Cancel any in-progress background file loading
496                    if let Some((provider, _)) = self.quick_open_registry.get_provider_for_input("")
497                    {
498                        if let Some(fp) = provider
499                            .as_any()
500                            .downcast_ref::<crate::input::quick_open::providers::FileProvider>(
501                        ) {
502                            fp.cancel_loading();
503                        }
504                    }
505                }
506                _ => {}
507            }
508        }
509
510        self.prompt = None;
511        self.pending_search_range = None;
512        self.status_message = Some(t!("search.cancelled").to_string());
513
514        // Restore original theme if we were in SelectTheme prompt
515        if let Some(original_theme) = theme_to_restore {
516            self.preview_theme(&original_theme);
517        }
518    }
519
520    /// Handle mouse wheel scroll in prompt with suggestions.
521    /// Returns true if scroll was handled, false if no prompt is active or has no suggestions.
522    pub fn handle_prompt_scroll(&mut self, delta: i32) -> bool {
523        if let Some(ref mut prompt) = self.prompt {
524            if prompt.suggestions.is_empty() {
525                return false;
526            }
527
528            let current = prompt.selected_suggestion.unwrap_or(0);
529            let len = prompt.suggestions.len();
530
531            // Calculate new position based on scroll direction
532            // delta < 0 = scroll up, delta > 0 = scroll down
533            let new_selected = if delta < 0 {
534                // Scroll up - move selection up (decrease index)
535                current.saturating_sub((-delta) as usize)
536            } else {
537                // Scroll down - move selection down (increase index)
538                (current + delta as usize).min(len.saturating_sub(1))
539            };
540
541            prompt.selected_suggestion = Some(new_selected);
542
543            // Update input to match selected suggestion for non-plugin prompts
544            if !matches!(prompt.prompt_type, PromptType::Plugin { .. }) {
545                if let Some(suggestion) = prompt.suggestions.get(new_selected) {
546                    prompt.input = suggestion.get_value().to_string();
547                    prompt.cursor_pos = prompt.input.len();
548                }
549            }
550
551            return true;
552        }
553        false
554    }
555
556    /// Get the confirmed input and prompt type, consuming the prompt
557    /// For command palette, returns the selected suggestion if available, otherwise the raw input
558    /// Returns (input, prompt_type, selected_index)
559    /// Returns None if trying to confirm a disabled command
560    pub fn confirm_prompt(&mut self) -> Option<(String, PromptType, Option<usize>)> {
561        if let Some(prompt) = self.prompt.take() {
562            let selected_index = prompt.selected_suggestion;
563            // For prompts with suggestions, prefer the selected suggestion over raw input
564            let mut final_input = if prompt.sync_input_on_navigate {
565                // When sync_input_on_navigate is set, the input field is kept in sync
566                // with the selected suggestion, so always use the input value
567                prompt.input.clone()
568            } else if matches!(
569                prompt.prompt_type,
570                PromptType::OpenFile
571                    | PromptType::SwitchProject
572                    | PromptType::SaveFileAs
573                    | PromptType::StopLspServer
574                    | PromptType::RestartLspServer
575                    | PromptType::SelectTheme { .. }
576                    | PromptType::SelectLocale
577                    | PromptType::SwitchToTab
578                    | PromptType::SetLanguage
579                    | PromptType::SetEncoding
580                    | PromptType::SetLineEnding
581                    | PromptType::Plugin { .. }
582            ) {
583                // Use the selected suggestion if any
584                if let Some(selected_idx) = prompt.selected_suggestion {
585                    if let Some(suggestion) = prompt.suggestions.get(selected_idx) {
586                        // Don't confirm disabled suggestions
587                        if suggestion.disabled {
588                            self.set_status_message(
589                                t!(
590                                    "error.command_not_available",
591                                    command = suggestion.text.clone()
592                                )
593                                .to_string(),
594                            );
595                            return None;
596                        }
597                        // Use the selected suggestion value
598                        suggestion.get_value().to_string()
599                    } else {
600                        prompt.input.clone()
601                    }
602                } else {
603                    prompt.input.clone()
604                }
605            } else {
606                prompt.input.clone()
607            };
608
609            // For StopLspServer/RestartLspServer, validate that the input matches a suggestion
610            if matches!(
611                prompt.prompt_type,
612                PromptType::StopLspServer | PromptType::RestartLspServer
613            ) {
614                let is_valid = prompt
615                    .suggestions
616                    .iter()
617                    .any(|s| s.text == final_input || s.get_value() == final_input);
618                if !is_valid {
619                    // Restore the prompt and don't confirm
620                    self.prompt = Some(prompt);
621                    self.set_status_message(
622                        t!("error.no_lsp_match", input = final_input.clone()).to_string(),
623                    );
624                    return None;
625                }
626            }
627
628            // For RemoveRuler, validate input against the suggestion list.
629            // If the user typed text, it must match a suggestion value to be accepted.
630            // If the input is empty, the pre-selected suggestion is used.
631            if matches!(prompt.prompt_type, PromptType::RemoveRuler) {
632                if prompt.input.is_empty() {
633                    // No typed text — use the selected suggestion
634                    if let Some(selected_idx) = prompt.selected_suggestion {
635                        if let Some(suggestion) = prompt.suggestions.get(selected_idx) {
636                            final_input = suggestion.get_value().to_string();
637                        }
638                    } else {
639                        self.prompt = Some(prompt);
640                        return None;
641                    }
642                } else {
643                    // User typed text — it must match a suggestion value
644                    let typed = prompt.input.trim().to_string();
645                    let matched = prompt.suggestions.iter().find(|s| s.get_value() == typed);
646                    if let Some(suggestion) = matched {
647                        final_input = suggestion.get_value().to_string();
648                    } else {
649                        // Typed text doesn't match any ruler — reject
650                        self.prompt = Some(prompt);
651                        return None;
652                    }
653                }
654            }
655
656            // Add to appropriate history based on prompt type
657            if let Some(key) = Self::prompt_type_to_history_key(&prompt.prompt_type) {
658                let history = self.get_or_create_prompt_history(&key);
659                history.push(final_input.clone());
660                history.reset_navigation();
661            }
662
663            Some((final_input, prompt.prompt_type, selected_index))
664        } else {
665            None
666        }
667    }
668
669    /// Check if currently in prompt mode
670    pub fn is_prompting(&self) -> bool {
671        self.prompt.is_some()
672    }
673
674    /// Get or create a prompt history for the given key
675    pub(super) fn get_or_create_prompt_history(
676        &mut self,
677        key: &str,
678    ) -> &mut crate::input::input_history::InputHistory {
679        self.prompt_histories.entry(key.to_string()).or_default()
680    }
681
682    /// Get a prompt history for the given key (immutable)
683    pub(super) fn get_prompt_history(
684        &self,
685        key: &str,
686    ) -> Option<&crate::input::input_history::InputHistory> {
687        self.prompt_histories.get(key)
688    }
689
690    /// Get the history key for a prompt type
691    pub(super) fn prompt_type_to_history_key(
692        prompt_type: &crate::view::prompt::PromptType,
693    ) -> Option<String> {
694        use crate::view::prompt::PromptType;
695        match prompt_type {
696            PromptType::Search | PromptType::ReplaceSearch | PromptType::QueryReplaceSearch => {
697                Some("search".to_string())
698            }
699            PromptType::Replace { .. } | PromptType::QueryReplace { .. } => {
700                Some("replace".to_string())
701            }
702            PromptType::GotoLine => Some("goto_line".to_string()),
703            PromptType::Plugin { custom_type } => Some(format!("plugin:{}", custom_type)),
704            _ => None,
705        }
706    }
707
708    /// Get the current global editor mode (e.g., "vi-normal", "vi-insert")
709    /// Returns None if no special mode is active
710    pub fn editor_mode(&self) -> Option<String> {
711        self.editor_mode.clone()
712    }
713
714    /// Get access to the command registry
715    pub fn command_registry(&self) -> &Arc<RwLock<CommandRegistry>> {
716        &self.command_registry
717    }
718
719    /// Get access to the plugin manager
720    pub fn plugin_manager(&self) -> &PluginManager {
721        &self.plugin_manager
722    }
723
724    /// Get mutable access to the plugin manager
725    pub fn plugin_manager_mut(&mut self) -> &mut PluginManager {
726        &mut self.plugin_manager
727    }
728
729    /// Check if file explorer has focus
730    pub fn file_explorer_is_focused(&self) -> bool {
731        self.key_context == KeyContext::FileExplorer
732    }
733
734    /// Get current prompt input (for display)
735    pub fn prompt_input(&self) -> Option<&str> {
736        self.prompt.as_ref().map(|p| p.input.as_str())
737    }
738
739    /// Check if the active cursor currently has a selection
740    pub fn has_active_selection(&self) -> bool {
741        self.active_cursors().primary().selection_range().is_some()
742    }
743
744    /// Get mutable reference to prompt (for input handling)
745    pub fn prompt_mut(&mut self) -> Option<&mut Prompt> {
746        self.prompt.as_mut()
747    }
748
749    /// Set a status message to display in the status bar
750    pub fn set_status_message(&mut self, message: String) {
751        tracing::info!(target: "status", "{}", message);
752        self.plugin_status_message = None;
753        self.status_message = Some(message);
754    }
755
756    /// Get the current status message
757    pub fn get_status_message(&self) -> Option<&String> {
758        self.plugin_status_message
759            .as_ref()
760            .or(self.status_message.as_ref())
761    }
762
763    /// Get accumulated plugin errors (for test assertions)
764    /// Returns all error messages that were detected in plugin status messages
765    pub fn get_plugin_errors(&self) -> &[String] {
766        &self.plugin_errors
767    }
768
769    /// Clear accumulated plugin errors
770    pub fn clear_plugin_errors(&mut self) {
771        self.plugin_errors.clear();
772    }
773
774    /// Update prompt suggestions based on current input
775    pub fn update_prompt_suggestions(&mut self) {
776        // Extract prompt type and input to avoid borrow checker issues
777        let (prompt_type, input) = if let Some(prompt) = &self.prompt {
778            (prompt.prompt_type.clone(), prompt.input.clone())
779        } else {
780            return;
781        };
782
783        match prompt_type {
784            PromptType::QuickOpen => {
785                // Update Quick Open suggestions based on prefix
786                self.update_quick_open_suggestions(&input);
787            }
788            PromptType::Search | PromptType::ReplaceSearch | PromptType::QueryReplaceSearch => {
789                // Update incremental search highlights as user types
790                self.update_search_highlights(&input);
791                // Reset history navigation when user types - allows Up to navigate history
792                if let Some(history) = self.prompt_histories.get_mut("search") {
793                    history.reset_navigation();
794                }
795            }
796            PromptType::Replace { .. } | PromptType::QueryReplace { .. } => {
797                // Reset history navigation when user types - allows Up to navigate history
798                if let Some(history) = self.prompt_histories.get_mut("replace") {
799                    history.reset_navigation();
800                }
801            }
802            PromptType::GotoLine => {
803                // Reset history navigation when user types - allows Up to navigate history
804                if let Some(history) = self.prompt_histories.get_mut("goto_line") {
805                    history.reset_navigation();
806                }
807            }
808            PromptType::OpenFile | PromptType::SwitchProject | PromptType::SaveFileAs => {
809                // For OpenFile/SwitchProject/SaveFileAs, update the file browser filter (native implementation)
810                self.update_file_open_filter();
811            }
812            PromptType::Plugin { custom_type } => {
813                // Reset history navigation when user types - allows Up to navigate history
814                let key = format!("plugin:{}", custom_type);
815                if let Some(history) = self.prompt_histories.get_mut(&key) {
816                    history.reset_navigation();
817                }
818                // Fire plugin hook for prompt input change
819                use crate::services::plugins::hooks::HookArgs;
820                self.plugin_manager.run_hook(
821                    "prompt_changed",
822                    HookArgs::PromptChanged {
823                        prompt_type: custom_type,
824                        input,
825                    },
826                );
827                // Apply fuzzy filtering if original_suggestions is set.
828                // Note: filter_suggestions checks suggestions_set_for_input to skip
829                // filtering if the plugin has already provided filtered results for
830                // this input (handles the async race condition with run_hook).
831                if let Some(prompt) = &mut self.prompt {
832                    prompt.filter_suggestions(false);
833                }
834            }
835            PromptType::SwitchToTab
836            | PromptType::SelectTheme { .. }
837            | PromptType::StopLspServer
838            | PromptType::RestartLspServer
839            | PromptType::SetLanguage
840            | PromptType::SetEncoding
841            | PromptType::SetLineEnding => {
842                if let Some(prompt) = &mut self.prompt {
843                    prompt.filter_suggestions(false);
844                }
845            }
846            PromptType::SelectLocale => {
847                // Locale selection also matches on description (language names)
848                if let Some(prompt) = &mut self.prompt {
849                    prompt.filter_suggestions(true);
850                }
851            }
852            _ => {}
853        }
854    }
855}