fresh/app/prompt_lifecycle.rs
1//! Prompt/minibuffer lifecycle on `Editor`.
2//!
3//! Starting/canceling/confirming prompts, scrolling suggestions,
4//! managing prompt history per type, building suggestion lists, plus
5//! the file-open/quick-open prompt setup helpers.
6
7use std::path::PathBuf;
8use std::sync::{Arc, RwLock};
9
10use rust_i18n::t;
11
12use crate::input::command_registry::CommandRegistry;
13use crate::input::commands::Suggestion;
14use crate::input::keybindings::KeyContext;
15use crate::input::quick_open::{BufferInfo, QuickOpenContext};
16use crate::services::async_bridge::AsyncMessage;
17use crate::services::plugins::PluginManager;
18use crate::view::prompt::{Prompt, PromptType};
19
20use super::file_open;
21use super::Editor;
22
23impl Editor {
24 // Prompt/Minibuffer control methods
25
26 /// Start a new prompt (enter minibuffer mode)
27 pub fn start_prompt(&mut self, message: String, prompt_type: PromptType) {
28 self.start_prompt_with_suggestions(message, prompt_type, Vec::new());
29 }
30
31 /// Start a search prompt with an optional selection scope
32 ///
33 /// When `use_selection_range` is true and a single-line selection is present,
34 /// the search will be restricted to that range once confirmed.
35 pub(super) fn start_search_prompt(
36 &mut self,
37 message: String,
38 prompt_type: PromptType,
39 use_selection_range: bool,
40 ) {
41 // Reset any previously stored selection range
42 self.pending_search_range = None;
43
44 let selection_range = self.active_cursors().primary().selection_range();
45
46 let selected_text = if let Some(range) = selection_range.clone() {
47 let state = self.active_state_mut();
48 let text = state.get_text_range(range.start, range.end);
49 if !text.contains('\n') && !text.is_empty() {
50 Some(text)
51 } else {
52 None
53 }
54 } else {
55 None
56 };
57
58 if use_selection_range {
59 self.pending_search_range = selection_range;
60 }
61
62 // Determine the default text: selection > last history > empty
63 let from_history = selected_text.is_none();
64 let default_text = selected_text.or_else(|| {
65 self.get_prompt_history("search")
66 .and_then(|h| h.last().map(|s| s.to_string()))
67 });
68
69 // Start the prompt
70 self.start_prompt(message, prompt_type);
71
72 // Pre-fill with default text if available
73 if let Some(text) = default_text {
74 if let Some(ref mut prompt) = self.prompt {
75 prompt.set_input(text.clone());
76 prompt.selection_anchor = Some(0);
77 prompt.cursor_pos = text.len();
78 }
79 if from_history {
80 self.get_or_create_prompt_history("search").init_at_last();
81 }
82 self.update_search_highlights(&text);
83 }
84 }
85
86 /// Start a new prompt with autocomplete suggestions
87 pub fn start_prompt_with_suggestions(
88 &mut self,
89 message: String,
90 prompt_type: PromptType,
91 suggestions: Vec<Suggestion>,
92 ) {
93 // Dismiss transient popups and clear hover state when opening a prompt
94 self.on_editor_focus_lost();
95
96 // Clear search highlights when starting a new search prompt
97 // This ensures old highlights from previous searches don't persist
98 match prompt_type {
99 PromptType::Search | PromptType::ReplaceSearch | PromptType::QueryReplaceSearch => {
100 self.clear_search_highlights();
101 }
102 _ => {}
103 }
104
105 // Check if we need to update suggestions after creating the prompt
106 let needs_suggestions = matches!(
107 prompt_type,
108 PromptType::OpenFile | PromptType::SwitchProject | PromptType::SaveFileAs
109 );
110
111 self.prompt = Some(Prompt::with_suggestions(message, prompt_type, suggestions));
112
113 // For file and command prompts, populate initial suggestions
114 if needs_suggestions {
115 self.update_prompt_suggestions();
116 }
117 }
118
119 /// Start a new prompt with initial text
120 pub fn start_prompt_with_initial_text(
121 &mut self,
122 message: String,
123 prompt_type: PromptType,
124 initial_text: String,
125 ) {
126 // Dismiss transient popups and clear hover state when opening a prompt
127 self.on_editor_focus_lost();
128
129 self.prompt = Some(Prompt::with_initial_text(
130 message,
131 prompt_type,
132 initial_text,
133 ));
134 }
135
136 /// Start Quick Open prompt with command palette as default
137 pub fn start_quick_open(&mut self) {
138 self.start_quick_open_with_prefix(">");
139 }
140
141 /// Start Quick Open prompt with specified prefix
142 pub fn start_quick_open_with_prefix(&mut self, prefix: &str) {
143 self.on_editor_focus_lost();
144 self.status_message = None;
145 self.goto_line_preview = None;
146
147 let mut prompt = Prompt::with_suggestions(String::new(), PromptType::QuickOpen, vec![]);
148 prompt.input = prefix.to_string();
149 prompt.cursor_pos = prefix.len();
150 self.prompt = Some(prompt);
151
152 self.update_quick_open_suggestions(prefix);
153 }
154
155 /// Build a QuickOpenContext from current editor state
156 pub(super) fn build_quick_open_context(&self) -> QuickOpenContext {
157 let open_buffers = self
158 .buffers
159 .iter()
160 .filter_map(|(buffer_id, state)| {
161 let path = state.buffer.file_path()?;
162 let name = path
163 .file_name()
164 .map(|n| n.to_string_lossy().to_string())
165 .unwrap_or_else(|| format!("Buffer {}", buffer_id.0));
166 Some(BufferInfo {
167 id: buffer_id.0,
168 path: path.display().to_string(),
169 name,
170 modified: state.buffer.is_modified(),
171 })
172 })
173 .collect();
174
175 let has_lsp_config = {
176 let language = self
177 .buffers
178 .get(&self.active_buffer())
179 .map(|s| s.language.as_str());
180 language
181 .and_then(|lang| self.lsp.as_ref().and_then(|lsp| lsp.get_config(lang)))
182 .is_some()
183 };
184
185 QuickOpenContext {
186 cwd: self.working_dir.display().to_string(),
187 open_buffers,
188 active_buffer_id: self.active_buffer().0,
189 active_buffer_path: self
190 .active_state()
191 .buffer
192 .file_path()
193 .map(|p| p.display().to_string()),
194 has_selection: self.has_active_selection(),
195 key_context: self.key_context.clone(),
196 custom_contexts: self.active_custom_contexts.clone(),
197 buffer_mode: self
198 .buffer_metadata
199 .get(&self.active_buffer())
200 .and_then(|m| m.virtual_mode())
201 .map(|s| s.to_string()),
202 has_lsp_config,
203 relative_line_numbers: self.config.editor.relative_line_numbers,
204 }
205 }
206
207 /// Update Quick Open suggestions based on current input, dispatching through the registry
208 pub(super) fn update_quick_open_suggestions(&mut self, input: &str) {
209 let context = self.build_quick_open_context();
210 let suggestions = if let Some((provider, query)) =
211 self.quick_open_registry.get_provider_for_input(input)
212 {
213 provider.suggestions(query, &context)
214 } else {
215 vec![]
216 };
217
218 if let Some(prompt) = &mut self.prompt {
219 prompt.suggestions = suggestions;
220 prompt.selected_suggestion = if prompt.suggestions.is_empty() {
221 None
222 } else {
223 Some(0)
224 };
225 }
226
227 // Live preview for the goto-line provider: if the input is ":<N>" for a
228 // valid absolute line N, jump there now so the user sees the target as
229 // they type (matches VSCode's Ctrl+P :<N> behavior). Otherwise, restore
230 // the cursor to its pre-preview position.
231 //
232 // Relative input (`:+N`/`:-N`) is intentionally not previewed: the
233 // target shifts on every digit typed, which is disorienting.
234 let input = input.trim();
235 let target = Self::parse_quick_open_goto_line_target(input);
236 self.apply_goto_line_preview(target);
237 }
238
239 /// Parse a Quick Open input string for a `:<N>` goto-line preview target.
240 /// Only absolute inputs are previewed; relative inputs return `None`.
241 pub(super) fn parse_quick_open_goto_line_target(input: &str) -> Option<usize> {
242 let rest = input.strip_prefix(':')?;
243 match crate::input::quick_open::parse_goto_line_input(rest) {
244 Some(crate::input::quick_open::GotoLineTarget::Absolute(n)) => Some(n),
245 _ => None,
246 }
247 }
248
249 /// Apply a live goto-line preview: jump to `target_line` (saving the
250 /// original cursor on the first jump) if `Some`, or restore the saved
251 /// cursor if `None`.
252 ///
253 /// Shared between Quick Open's `:N` syntax and the standalone `Goto Line`
254 /// prompt, which differ only in how the target line is parsed from input.
255 pub(super) fn apply_goto_line_preview(&mut self, target_line: Option<usize>) {
256 if let Some(line) = target_line {
257 self.save_goto_line_preview_snapshot();
258 self.goto_line_col(line, None);
259 // Record where the jump landed so restore can detect if the cursor
260 // has since moved (e.g., mouse click, external buffer edit).
261 let new_position = self.active_cursors().primary().position;
262 if let Some(snap) = self.goto_line_preview.as_mut() {
263 snap.last_jump_position = new_position;
264 }
265 } else {
266 self.restore_goto_line_preview_snapshot();
267 }
268 }
269
270 /// Save a snapshot of the active buffer's cursor and viewport so the
271 /// goto-line preview can later restore it. No-op if a snapshot is already
272 /// in place (the saved state should always be the pre-preview one).
273 pub(super) fn save_goto_line_preview_snapshot(&mut self) {
274 if self.goto_line_preview.is_some() {
275 return;
276 }
277
278 let buffer_id = self.active_buffer();
279 let split_id = self.split_manager.active_split();
280 let (cursor_id, position, anchor, sticky_column) = {
281 let cursors = self.active_cursors();
282 let primary = cursors.primary();
283 (
284 cursors.primary_id(),
285 primary.position,
286 primary.anchor,
287 primary.sticky_column,
288 )
289 };
290 let (viewport_top_byte, viewport_top_view_line_offset, viewport_left_column) = {
291 let vp = self.active_viewport();
292 (vp.top_byte, vp.top_view_line_offset, vp.left_column)
293 };
294
295 self.goto_line_preview = Some(super::GotoLinePreviewSnapshot {
296 buffer_id,
297 split_id,
298 cursor_id,
299 position,
300 anchor,
301 sticky_column,
302 viewport_top_byte,
303 viewport_top_view_line_offset,
304 viewport_left_column,
305 // Before the first jump the cursor is still at the pre-preview
306 // position; `apply_goto_line_preview` overwrites this with the
307 // jump target immediately after calling `goto_line_col`.
308 last_jump_position: position,
309 });
310 }
311
312 /// If a goto-line preview snapshot exists, restore the active split's
313 /// cursor and viewport to the saved state and clear the snapshot.
314 ///
315 /// The snapshot is only applied if the editor is still in exactly the
316 /// state the last preview jump left it in: same active buffer, same split,
317 /// cursor still at `last_jump_position`. Any deviation (user mouse-clicked,
318 /// an async edit shifted the cursor, focus moved elsewhere, …) means the
319 /// pre-preview state is stale and we simply discard the snapshot.
320 pub(super) fn restore_goto_line_preview_snapshot(&mut self) {
321 let Some(snap) = self.goto_line_preview.take() else {
322 return;
323 };
324
325 // If the active buffer/split has changed (shouldn't happen during a
326 // quick-open prompt, but be defensive), just drop the snapshot.
327 if self.active_buffer() != snap.buffer_id
328 || self.split_manager.active_split() != snap.split_id
329 {
330 return;
331 }
332
333 let cursors = self.active_cursors();
334 let current = cursors.primary();
335
336 // Cursor no longer where the preview left it → someone else moved it
337 // (mouse click, external edit via `adjust_for_edit`, …). Drop without
338 // restoring to avoid rubber-banding over that deliberate state.
339 if current.position != snap.last_jump_position {
340 return;
341 }
342 let event = crate::model::event::Event::MoveCursor {
343 cursor_id: snap.cursor_id,
344 old_position: current.position,
345 new_position: snap.position,
346 old_anchor: current.anchor,
347 new_anchor: snap.anchor,
348 old_sticky_column: current.sticky_column,
349 new_sticky_column: snap.sticky_column,
350 };
351
352 let state = self.buffers.get_mut(&snap.buffer_id).unwrap();
353 let view_state = self.split_view_states.get_mut(&snap.split_id).unwrap();
354 state.apply(&mut view_state.cursors, &event);
355
356 let vp = &mut view_state.viewport;
357 vp.top_byte = snap.viewport_top_byte;
358 vp.top_view_line_offset = snap.viewport_top_view_line_offset;
359 vp.left_column = snap.viewport_left_column;
360 // The cursor we just restored is already consistent with this
361 // viewport; don't let ensure_visible re-scroll on the next render.
362 vp.set_skip_ensure_visible();
363 }
364
365 /// Cancel search/replace prompts if one is active.
366 /// Called when focus leaves the editor (e.g., switching buffers, focusing file explorer).
367 pub(super) fn cancel_search_prompt_if_active(&mut self) {
368 if let Some(ref prompt) = self.prompt {
369 if matches!(
370 prompt.prompt_type,
371 PromptType::Search
372 | PromptType::ReplaceSearch
373 | PromptType::Replace { .. }
374 | PromptType::QueryReplaceSearch
375 | PromptType::QueryReplace { .. }
376 | PromptType::QueryReplaceConfirm
377 ) {
378 self.prompt = None;
379 // Also cancel interactive replace if active
380 self.interactive_replace_state = None;
381 // Clear search highlights from current buffer
382 let ns = self.search_namespace.clone();
383 let state = self.active_state_mut();
384 state.overlays.clear_namespace(&ns, &mut state.marker_list);
385 }
386 }
387 }
388
389 /// Pre-fill the Open File prompt input with the current buffer directory
390 pub(super) fn prefill_open_file_prompt(&mut self) {
391 // With the native file browser, the directory is shown from file_open_state.current_dir
392 // in the prompt rendering. The prompt.input is just the filter/filename, so we
393 // start with an empty input.
394 if let Some(prompt) = self.prompt.as_mut() {
395 if prompt.prompt_type == PromptType::OpenFile {
396 prompt.input.clear();
397 prompt.cursor_pos = 0;
398 prompt.selection_anchor = None;
399 }
400 }
401 }
402
403 /// Initialize the file open dialog state
404 ///
405 /// Called when the Open File prompt is started. Determines the initial directory
406 /// (from current buffer's directory or working directory) and triggers async
407 /// directory loading.
408 pub(super) fn init_file_open_state(&mut self) {
409 // Determine initial directory
410 let buffer_id = self.active_buffer();
411
412 // For terminal buffers, use the terminal's initial CWD or fall back to project root
413 // This avoids showing the terminal backing file directory which is confusing for users
414 let initial_dir = if self.is_terminal_buffer(buffer_id) {
415 self.get_terminal_id(buffer_id)
416 .and_then(|tid| self.terminal_manager.get(tid))
417 .and_then(|handle| handle.cwd())
418 .unwrap_or_else(|| self.working_dir.clone())
419 } else {
420 self.active_state()
421 .buffer
422 .file_path()
423 .and_then(|path| path.parent())
424 .map(|p| p.to_path_buf())
425 .unwrap_or_else(|| self.working_dir.clone())
426 };
427
428 // Create the file open state with config-based show_hidden setting
429 let show_hidden = self.config.file_browser.show_hidden;
430 self.file_open_state = Some(file_open::FileOpenState::new(
431 initial_dir.clone(),
432 show_hidden,
433 self.authority.filesystem.clone(),
434 ));
435
436 // Start async directory loading and async shortcuts loading in parallel
437 self.load_file_open_directory(initial_dir);
438 self.load_file_open_shortcuts_async();
439 }
440
441 /// Initialize the folder open dialog state
442 ///
443 /// Called when the Switch Project prompt is started. Starts from the current working
444 /// directory and triggers async directory loading.
445 pub(super) fn init_folder_open_state(&mut self) {
446 // Start from the current working directory
447 let initial_dir = self.working_dir.clone();
448
449 // Create the file open state with config-based show_hidden setting
450 let show_hidden = self.config.file_browser.show_hidden;
451 self.file_open_state = Some(file_open::FileOpenState::new(
452 initial_dir.clone(),
453 show_hidden,
454 self.authority.filesystem.clone(),
455 ));
456
457 // Start async directory loading and async shortcuts loading in parallel
458 self.load_file_open_directory(initial_dir);
459 self.load_file_open_shortcuts_async();
460 }
461
462 /// Change the working directory to a new path
463 ///
464 /// This requests a full editor restart with the new working directory.
465 /// The main loop will drop the current editor instance and create a fresh
466 /// one pointing to the new directory. This ensures:
467 /// - All buffers are cleanly closed
468 /// - LSP servers are properly shut down and restarted with new root
469 /// - Plugins are cleanly restarted
470 /// - No state leaks between projects
471 pub fn change_working_dir(&mut self, new_path: PathBuf) {
472 // Canonicalize the path to resolve symlinks and normalize
473 let new_path = new_path.canonicalize().unwrap_or(new_path);
474
475 // Request a restart with the new working directory
476 // The main loop will handle creating a fresh editor instance
477 self.request_restart(new_path);
478 }
479
480 /// Load directory contents for the file open dialog
481 pub(super) fn load_file_open_directory(&mut self, path: PathBuf) {
482 // Update state to loading
483 if let Some(state) = &mut self.file_open_state {
484 state.current_dir = path.clone();
485 state.loading = true;
486 state.error = None;
487 state.update_shortcuts();
488 }
489
490 // Use tokio runtime to load directory
491 if let Some(ref runtime) = self.tokio_runtime {
492 let fs_manager = self.fs_manager.clone();
493 let sender = self.async_bridge.as_ref().map(|b| b.sender());
494
495 runtime.spawn(async move {
496 let result = fs_manager.list_dir_with_metadata(path).await;
497 if let Some(sender) = sender {
498 // Receiver may have been dropped if the dialog was closed.
499 #[allow(clippy::let_underscore_must_use)]
500 let _ = sender.send(AsyncMessage::FileOpenDirectoryLoaded(result));
501 }
502 });
503 } else {
504 // No runtime, set error
505 if let Some(state) = &mut self.file_open_state {
506 state.set_error("Async runtime not available".to_string());
507 }
508 }
509 }
510
511 /// Handle file open directory load result
512 pub(super) fn handle_file_open_directory_loaded(
513 &mut self,
514 result: std::io::Result<Vec<crate::services::fs::DirEntry>>,
515 ) {
516 match result {
517 Ok(entries) => {
518 if let Some(state) = &mut self.file_open_state {
519 state.set_entries(entries);
520 }
521 // Re-apply filter from prompt (entries were just loaded, filter needs to select matching entry)
522 let filter = self
523 .prompt
524 .as_ref()
525 .map(|p| p.input.clone())
526 .unwrap_or_default();
527 if !filter.is_empty() {
528 if let Some(state) = &mut self.file_open_state {
529 state.apply_filter(&filter);
530 }
531 }
532 }
533 Err(e) => {
534 if let Some(state) = &mut self.file_open_state {
535 state.set_error(e.to_string());
536 }
537 }
538 }
539 }
540
541 /// Load async shortcuts (documents, downloads, Windows drive letters) in the background.
542 /// This prevents the UI from hanging when checking paths that may be slow or unreachable.
543 /// See issue #903.
544 pub(super) fn load_file_open_shortcuts_async(&mut self) {
545 if let Some(ref runtime) = self.tokio_runtime {
546 let filesystem = self.authority.filesystem.clone();
547 let sender = self.async_bridge.as_ref().map(|b| b.sender());
548
549 runtime.spawn(async move {
550 // Run the blocking filesystem checks in a separate thread
551 let shortcuts = tokio::task::spawn_blocking(move || {
552 file_open::FileOpenState::build_shortcuts_async(&*filesystem)
553 })
554 .await
555 .unwrap_or_default();
556
557 if let Some(sender) = sender {
558 // Receiver may have been dropped if the dialog was closed.
559 #[allow(clippy::let_underscore_must_use)]
560 let _ = sender.send(AsyncMessage::FileOpenShortcutsLoaded(shortcuts));
561 }
562 });
563 }
564 }
565
566 /// Handle async shortcuts load result
567 pub(super) fn handle_file_open_shortcuts_loaded(
568 &mut self,
569 shortcuts: Vec<file_open::NavigationShortcut>,
570 ) {
571 if let Some(state) = &mut self.file_open_state {
572 state.merge_async_shortcuts(shortcuts);
573 }
574 }
575
576 /// Tear down the standalone preview state used by the Live Grep
577 /// floating overlay (issue #1796). Drops the inline view state
578 /// and closes any buffers we loaded purely for preview (buffers
579 /// the user already had open are left untouched).
580 pub(crate) fn cleanup_overlay_preview(&mut self) {
581 let to_close: Vec<crate::model::event::BufferId> =
582 if let Some(state) = self.overlay_preview_state.take() {
583 state.loaded_buffers.into_iter().collect()
584 } else {
585 Vec::new()
586 };
587 for buffer_id in to_close {
588 // close_buffer is the user-facing close (errors on
589 // unsaved changes). Preview-loaded buffers are read-only
590 // / unmodified by definition, so this should always
591 // succeed. Tolerate failure silently — leaving an extra
592 // hidden buffer around is preferable to crashing.
593 if let Err(e) = self.close_buffer(buffer_id) {
594 tracing::warn!("Failed to close overlay preview buffer: {}", e);
595 }
596 }
597 }
598
599 /// Snapshot the current prompt's suggestions as a list of
600 /// `GrepMatch` records, so Resume can re-display them without
601 /// re-running ripgrep and Quickfix export can hand them to the
602 /// Utility Dock. Parses each suggestion's text as
603 /// `path:line[:col]` (the format the live_grep finder emits).
604 pub(crate) fn snapshot_prompt_results_for_grep(
605 &self,
606 prompt: &crate::view::prompt::Prompt,
607 ) -> Vec<crate::services::live_grep_state::GrepMatch> {
608 use crate::input::quick_open::parse_path_line_col;
609 // Suggestions emitted by the Finder library use `value` as an
610 // opaque index (`"0"`, `"1"`, …) and put `path:line[:col]` in
611 // `text`. Parse `text` first; fall back to `value` only if
612 // `text` lacks a path-shaped segment (a Resume-replay where
613 // we previously stored `path:line:col` in `value` directly).
614 prompt
615 .suggestions
616 .iter()
617 .filter(|s| !s.disabled)
618 .filter_map(|s| {
619 let from_text = parse_path_line_col(&s.text);
620 let (file, line, column) = if !from_text.0.is_empty() && from_text.1.is_some() {
621 from_text
622 } else if let Some(v) = s.value.as_deref() {
623 parse_path_line_col(v)
624 } else {
625 from_text
626 };
627 if file.is_empty() {
628 return None;
629 }
630 Some(crate::services::live_grep_state::GrepMatch {
631 file,
632 line: line.unwrap_or(1),
633 column: column.unwrap_or(1),
634 content: s.description.clone().unwrap_or_default(),
635 })
636 })
637 .collect()
638 }
639
640 /// Cancel the current prompt and return to normal mode
641 pub fn cancel_prompt(&mut self) {
642 // Extract theme to restore if this is a SelectTheme prompt
643 let theme_to_restore = if let Some(ref prompt) = self.prompt {
644 if let PromptType::SelectTheme { original_theme } = &prompt.prompt_type {
645 Some(original_theme.clone())
646 } else {
647 None
648 }
649 } else {
650 None
651 };
652
653 // Determine prompt type and reset appropriate history navigation
654 if let Some(ref prompt) = self.prompt {
655 // Reset history navigation for this prompt type
656 if let Some(key) = Self::prompt_type_to_history_key(&prompt.prompt_type) {
657 if let Some(history) = self.prompt_histories.get_mut(&key) {
658 history.reset_navigation();
659 }
660 }
661 match &prompt.prompt_type {
662 PromptType::Search | PromptType::ReplaceSearch | PromptType::QueryReplaceSearch => {
663 self.clear_search_highlights();
664 }
665 PromptType::Plugin { custom_type } => {
666 // Fire plugin hook for prompt cancellation
667 use crate::services::plugins::hooks::HookArgs;
668 self.plugin_manager.run_hook(
669 "prompt_cancelled",
670 HookArgs::PromptCancelled {
671 prompt_type: custom_type.clone(),
672 input: prompt.input.clone(),
673 },
674 );
675 // Capture Live Grep state on cancel for Resume
676 // (Action::ResumeLiveGrep) — reads from
677 // editor.live_grep_last_state. Detection by
678 // custom_type rather than dedicated PromptType
679 // because the live_grep plugin drives the prompt.
680 if custom_type == "live-grep" {
681 let cached = self.snapshot_prompt_results_for_grep(prompt);
682 // Only cache when there's something useful to
683 // resume — dismissing the prompt before
684 // typing or before any results streamed in
685 // shouldn't mark the cache as "valid", or
686 // Resume sees `cached_results.is_some()`
687 // (empty Vec) and enters the restore branch
688 // with zero entries, producing an empty
689 // popup.
690 if !prompt.input.is_empty() && !cached.is_empty() {
691 self.live_grep_last_state =
692 Some(crate::services::live_grep_state::LiveGrepLastState {
693 query: prompt.input.clone(),
694 selected_index: prompt.selected_suggestion,
695 cached_results: Some(cached),
696 cached_at: Some(std::time::Instant::now()),
697 last_results_snapshot_id: None,
698 });
699 }
700 }
701 }
702 PromptType::LiveGrep => {
703 let cached = self.snapshot_prompt_results_for_grep(prompt);
704 if !prompt.input.is_empty() && !cached.is_empty() {
705 self.live_grep_last_state =
706 Some(crate::services::live_grep_state::LiveGrepLastState {
707 query: prompt.input.clone(),
708 selected_index: prompt.selected_suggestion,
709 cached_results: Some(cached),
710 cached_at: Some(std::time::Instant::now()),
711 last_results_snapshot_id: None,
712 });
713 }
714 }
715 PromptType::LspRename { overlay_handle, .. } => {
716 // Remove the rename overlay when cancelling
717 let remove_overlay_event = crate::model::event::Event::RemoveOverlay {
718 handle: overlay_handle.clone(),
719 };
720 self.apply_event_to_active_buffer(&remove_overlay_event);
721 }
722 PromptType::OpenFile | PromptType::SwitchProject | PromptType::SaveFileAs => {
723 // Clear file browser state
724 self.file_open_state = None;
725 self.file_browser_layout = None;
726
727 // Cancelling a Save-As that was opened as part of the
728 // "save and quit" chain aborts the quit — the user
729 // explicitly chose not to name this buffer, so we'd
730 // rather keep the editor open than drop their content.
731 if matches!(prompt.prompt_type, PromptType::SaveFileAs)
732 && !self.pending_quit_unnamed_save.is_empty()
733 {
734 self.pending_quit_unnamed_save.clear();
735 self.set_status_message(t!("buffer.close_cancelled").to_string());
736 }
737 }
738 PromptType::AsyncPrompt => {
739 // Resolve the pending async prompt callback with null (cancelled)
740 if let Some(callback_id) = self.pending_async_prompt_callback.take() {
741 self.plugin_manager
742 .resolve_callback(callback_id, "null".to_string());
743 }
744 }
745 PromptType::QuickOpen => {
746 // Cancel any in-progress background file loading
747 if let Some((provider, _)) = self.quick_open_registry.get_provider_for_input("")
748 {
749 if let Some(fp) = provider
750 .as_any()
751 .downcast_ref::<crate::input::quick_open::providers::FileProvider>(
752 ) {
753 fp.cancel_loading();
754 }
755 }
756 // Undo any live goto-line preview so the cursor returns to
757 // where it was before the prompt was opened.
758 self.restore_goto_line_preview_snapshot();
759 }
760 PromptType::GotoLine => {
761 // Undo any live goto-line preview so the cursor returns to
762 // where it was before the prompt was opened.
763 self.restore_goto_line_preview_snapshot();
764 }
765 _ => {}
766 }
767 }
768
769 // If we're closing a floating-overlay prompt (Live Grep,
770 // issue #1796), tear down the phantom preview leaf and close
771 // any buffers we loaded purely to feed the preview pane. The
772 // user's split tree and originally-open buffers are
773 // untouched.
774 let was_overlay = self.prompt.as_ref().is_some_and(|p| p.overlay);
775 if was_overlay {
776 self.cleanup_overlay_preview();
777 }
778
779 self.prompt = None;
780 self.pending_search_range = None;
781 self.status_message = Some(t!("search.cancelled").to_string());
782
783 // Restore original theme if we were in SelectTheme prompt
784 if let Some(original_theme) = theme_to_restore {
785 self.preview_theme(&original_theme);
786 }
787 }
788
789 /// Handle mouse wheel scroll in prompt with suggestions.
790 /// Returns true if scroll was handled, false if no prompt is active or has no suggestions.
791 pub fn handle_prompt_scroll(&mut self, delta: i32) -> bool {
792 if let Some(ref mut prompt) = self.prompt {
793 if prompt.suggestions.is_empty() {
794 return false;
795 }
796
797 let current = prompt.selected_suggestion.unwrap_or(0);
798 let len = prompt.suggestions.len();
799
800 // Calculate new position based on scroll direction
801 // delta < 0 = scroll up, delta > 0 = scroll down
802 let new_selected = if delta < 0 {
803 // Scroll up - move selection up (decrease index)
804 current.saturating_sub((-delta) as usize)
805 } else {
806 // Scroll down - move selection down (increase index)
807 (current + delta as usize).min(len.saturating_sub(1))
808 };
809
810 prompt.selected_suggestion = Some(new_selected);
811
812 // Update input to match selected suggestion for non-plugin prompts
813 if !matches!(prompt.prompt_type, PromptType::Plugin { .. }) {
814 if let Some(suggestion) = prompt.suggestions.get(new_selected) {
815 prompt.input = suggestion.get_value().to_string();
816 prompt.cursor_pos = prompt.input.len();
817 }
818 }
819
820 return true;
821 }
822 false
823 }
824
825 /// Get the confirmed input and prompt type, consuming the prompt
826 /// For command palette, returns the selected suggestion if available, otherwise the raw input
827 /// Returns (input, prompt_type, selected_index)
828 /// Returns None if trying to confirm a disabled command
829 pub fn confirm_prompt(&mut self) -> Option<(String, PromptType, Option<usize>)> {
830 if let Some(prompt) = self.prompt.take() {
831 // Capture Live Grep state on confirm too (issue #1796).
832 // `cancel_prompt` already does this; without it here,
833 // pressing Enter on a result jumps to the file but loses
834 // the Resume cache, so `Action::ResumeLiveGrep` then opens
835 // a fresh-empty popup instead of returning the user to
836 // their match list. Same gates as cancel: only cache when
837 // query and snapshot are both non-empty.
838 let is_live_grep = match &prompt.prompt_type {
839 PromptType::LiveGrep => true,
840 PromptType::Plugin { custom_type } => custom_type == "live-grep",
841 _ => false,
842 };
843 if is_live_grep {
844 let cached = self.snapshot_prompt_results_for_grep(&prompt);
845 if !prompt.input.is_empty() && !cached.is_empty() {
846 self.live_grep_last_state =
847 Some(crate::services::live_grep_state::LiveGrepLastState {
848 query: prompt.input.clone(),
849 selected_index: prompt.selected_suggestion,
850 cached_results: Some(cached),
851 cached_at: Some(std::time::Instant::now()),
852 last_results_snapshot_id: None,
853 });
854 }
855 }
856 // Tear down the floating-overlay preview state on
857 // confirm too — the user is committing to a result and
858 // navigating to it, so the preview-only buffers should
859 // be cleaned up the same way they are on cancel.
860 if prompt.overlay {
861 self.cleanup_overlay_preview();
862 }
863 let selected_index = prompt.selected_suggestion;
864 // For prompts with suggestions, prefer the selected suggestion over raw input
865 let mut final_input = if prompt.sync_input_on_navigate {
866 // When sync_input_on_navigate is set, the input field is kept in sync
867 // with the selected suggestion, so always use the input value
868 prompt.input.clone()
869 } else if matches!(
870 prompt.prompt_type,
871 PromptType::OpenFile
872 | PromptType::SwitchProject
873 | PromptType::SaveFileAs
874 | PromptType::StopLspServer
875 | PromptType::RestartLspServer
876 | PromptType::SelectTheme { .. }
877 | PromptType::SelectLocale
878 | PromptType::SwitchToTab
879 | PromptType::SetLanguage
880 | PromptType::SetEncoding
881 | PromptType::SetLineEnding
882 | PromptType::Plugin { .. }
883 ) {
884 // Use the selected suggestion if any
885 if let Some(selected_idx) = prompt.selected_suggestion {
886 if let Some(suggestion) = prompt.suggestions.get(selected_idx) {
887 // Don't confirm disabled suggestions
888 if suggestion.disabled {
889 self.set_status_message(
890 t!(
891 "error.command_not_available",
892 command = suggestion.text.clone()
893 )
894 .to_string(),
895 );
896 return None;
897 }
898 // Use the selected suggestion value
899 suggestion.get_value().to_string()
900 } else {
901 prompt.input.clone()
902 }
903 } else {
904 prompt.input.clone()
905 }
906 } else {
907 prompt.input.clone()
908 };
909
910 // For StopLspServer/RestartLspServer, validate that the input matches a suggestion
911 if matches!(
912 prompt.prompt_type,
913 PromptType::StopLspServer | PromptType::RestartLspServer
914 ) {
915 let is_valid = prompt
916 .suggestions
917 .iter()
918 .any(|s| s.text == final_input || s.get_value() == final_input);
919 if !is_valid {
920 // Restore the prompt and don't confirm
921 self.prompt = Some(prompt);
922 self.set_status_message(
923 t!("error.no_lsp_match", input = final_input.clone()).to_string(),
924 );
925 return None;
926 }
927 }
928
929 // For RemoveRuler, validate input against the suggestion list.
930 // If the user typed text, it must match a suggestion value to be accepted.
931 // If the input is empty, the pre-selected suggestion is used.
932 if matches!(prompt.prompt_type, PromptType::RemoveRuler) {
933 if prompt.input.is_empty() {
934 // No typed text — use the selected suggestion
935 if let Some(selected_idx) = prompt.selected_suggestion {
936 if let Some(suggestion) = prompt.suggestions.get(selected_idx) {
937 final_input = suggestion.get_value().to_string();
938 }
939 } else {
940 self.prompt = Some(prompt);
941 return None;
942 }
943 } else {
944 // User typed text — it must match a suggestion value
945 let typed = prompt.input.trim().to_string();
946 let matched = prompt.suggestions.iter().find(|s| s.get_value() == typed);
947 if let Some(suggestion) = matched {
948 final_input = suggestion.get_value().to_string();
949 } else {
950 // Typed text doesn't match any ruler — reject
951 self.prompt = Some(prompt);
952 return None;
953 }
954 }
955 }
956
957 // Add to appropriate history based on prompt type
958 if let Some(key) = Self::prompt_type_to_history_key(&prompt.prompt_type) {
959 let history = self.get_or_create_prompt_history(&key);
960 history.push(final_input.clone());
961 history.reset_navigation();
962 }
963
964 Some((final_input, prompt.prompt_type, selected_index))
965 } else {
966 None
967 }
968 }
969
970 /// Check if currently in prompt mode
971 pub fn is_prompting(&self) -> bool {
972 self.prompt.is_some()
973 }
974
975 /// Get or create a prompt history for the given key
976 pub(super) fn get_or_create_prompt_history(
977 &mut self,
978 key: &str,
979 ) -> &mut crate::input::input_history::InputHistory {
980 self.prompt_histories.entry(key.to_string()).or_default()
981 }
982
983 /// Get a prompt history for the given key (immutable)
984 pub(super) fn get_prompt_history(
985 &self,
986 key: &str,
987 ) -> Option<&crate::input::input_history::InputHistory> {
988 self.prompt_histories.get(key)
989 }
990
991 /// Get the history key for a prompt type
992 pub(super) fn prompt_type_to_history_key(
993 prompt_type: &crate::view::prompt::PromptType,
994 ) -> Option<String> {
995 use crate::view::prompt::PromptType;
996 match prompt_type {
997 PromptType::Search | PromptType::ReplaceSearch | PromptType::QueryReplaceSearch => {
998 Some("search".to_string())
999 }
1000 PromptType::Replace { .. } | PromptType::QueryReplace { .. } => {
1001 Some("replace".to_string())
1002 }
1003 PromptType::GotoLine => Some("goto_line".to_string()),
1004 PromptType::Plugin { custom_type } => Some(format!("plugin:{}", custom_type)),
1005 _ => None,
1006 }
1007 }
1008
1009 /// Get the current global editor mode (e.g., "vi-normal", "vi-insert")
1010 /// Returns None if no special mode is active
1011 pub fn editor_mode(&self) -> Option<String> {
1012 self.editor_mode.clone()
1013 }
1014
1015 /// Get access to the command registry
1016 pub fn command_registry(&self) -> &Arc<RwLock<CommandRegistry>> {
1017 &self.command_registry
1018 }
1019
1020 /// Get access to the plugin manager
1021 pub fn plugin_manager(&self) -> &PluginManager {
1022 &self.plugin_manager
1023 }
1024
1025 /// Get mutable access to the plugin manager
1026 pub fn plugin_manager_mut(&mut self) -> &mut PluginManager {
1027 &mut self.plugin_manager
1028 }
1029
1030 /// Check if file explorer has focus
1031 pub fn file_explorer_is_focused(&self) -> bool {
1032 self.key_context == KeyContext::FileExplorer
1033 }
1034
1035 /// Get current prompt input (for display)
1036 pub fn prompt_input(&self) -> Option<&str> {
1037 self.prompt.as_ref().map(|p| p.input.as_str())
1038 }
1039
1040 /// Check if the active cursor currently has a selection
1041 pub fn has_active_selection(&self) -> bool {
1042 self.active_cursors().primary().selection_range().is_some()
1043 }
1044
1045 /// Get mutable reference to prompt (for input handling)
1046 pub fn prompt_mut(&mut self) -> Option<&mut Prompt> {
1047 self.prompt.as_mut()
1048 }
1049
1050 /// Set a status message to display in the status bar
1051 pub fn set_status_message(&mut self, message: String) {
1052 tracing::info!(target: "status", "{}", message);
1053 self.plugin_status_message = None;
1054 self.status_message = Some(message);
1055 }
1056
1057 /// Get the current status message
1058 pub fn get_status_message(&self) -> Option<&String> {
1059 self.plugin_status_message
1060 .as_ref()
1061 .or(self.status_message.as_ref())
1062 }
1063
1064 /// Get accumulated plugin errors (for test assertions)
1065 /// Returns all error messages that were detected in plugin status messages
1066 pub fn get_plugin_errors(&self) -> &[String] {
1067 &self.plugin_errors
1068 }
1069
1070 /// Clear accumulated plugin errors
1071 pub fn clear_plugin_errors(&mut self) {
1072 self.plugin_errors.clear();
1073 }
1074
1075 /// Update prompt suggestions based on current input
1076 pub fn update_prompt_suggestions(&mut self) {
1077 // Extract prompt type and input to avoid borrow checker issues
1078 let (prompt_type, input) = if let Some(prompt) = &self.prompt {
1079 (prompt.prompt_type.clone(), prompt.input.clone())
1080 } else {
1081 return;
1082 };
1083
1084 match prompt_type {
1085 PromptType::QuickOpen => {
1086 // Update Quick Open suggestions based on prefix
1087 self.update_quick_open_suggestions(&input);
1088 }
1089 PromptType::Search | PromptType::ReplaceSearch | PromptType::QueryReplaceSearch => {
1090 // Update incremental search highlights as user types
1091 self.update_search_highlights(&input);
1092 // Reset history navigation when user types - allows Up to navigate history
1093 if let Some(history) = self.prompt_histories.get_mut("search") {
1094 history.reset_navigation();
1095 }
1096 }
1097 PromptType::Replace { .. } | PromptType::QueryReplace { .. } => {
1098 // Reset history navigation when user types - allows Up to navigate history
1099 if let Some(history) = self.prompt_histories.get_mut("replace") {
1100 history.reset_navigation();
1101 }
1102 }
1103 PromptType::GotoLine => {
1104 // Reset history navigation when user types - allows Up to navigate Up arrow history
1105 if let Some(history) = self.prompt_histories.get_mut("goto_line") {
1106 history.reset_navigation();
1107 }
1108 // Live preview for absolute line numbers only. Signed
1109 // (`+N`/`-N`) inputs are relative, and previewing them as the
1110 // user types each digit is disorienting — preview only on
1111 // Enter for those.
1112 let target = match crate::input::quick_open::parse_goto_line_input(input.trim()) {
1113 Some(crate::input::quick_open::GotoLineTarget::Absolute(n)) => Some(n),
1114 _ => None,
1115 };
1116 self.apply_goto_line_preview(target);
1117 }
1118 PromptType::OpenFile | PromptType::SwitchProject | PromptType::SaveFileAs => {
1119 // For OpenFile/SwitchProject/SaveFileAs, update the file browser filter (native implementation)
1120 self.update_file_open_filter();
1121 }
1122 PromptType::Plugin { custom_type } => {
1123 // Reset history navigation when user types - allows Up to navigate history
1124 let key = format!("plugin:{}", custom_type);
1125 if let Some(history) = self.prompt_histories.get_mut(&key) {
1126 history.reset_navigation();
1127 }
1128 // Fire plugin hook for prompt input change
1129 use crate::services::plugins::hooks::HookArgs;
1130 self.plugin_manager.run_hook(
1131 "prompt_changed",
1132 HookArgs::PromptChanged {
1133 prompt_type: custom_type,
1134 input,
1135 },
1136 );
1137 // Apply fuzzy filtering if original_suggestions is set.
1138 // Note: filter_suggestions checks suggestions_set_for_input to skip
1139 // filtering if the plugin has already provided filtered results for
1140 // this input (handles the async race condition with run_hook).
1141 if let Some(prompt) = &mut self.prompt {
1142 prompt.filter_suggestions(false);
1143 }
1144 }
1145 PromptType::SwitchToTab
1146 | PromptType::SelectTheme { .. }
1147 | PromptType::StopLspServer
1148 | PromptType::RestartLspServer
1149 | PromptType::SetLanguage
1150 | PromptType::SetEncoding
1151 | PromptType::SetLineEnding => {
1152 if let Some(prompt) = &mut self.prompt {
1153 prompt.filter_suggestions(false);
1154 }
1155 }
1156 PromptType::SelectLocale => {
1157 // Locale selection also matches on description (language names)
1158 if let Some(prompt) = &mut self.prompt {
1159 prompt.filter_suggestions(true);
1160 }
1161 }
1162 _ => {}
1163 }
1164 }
1165}