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