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