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