1use std::path::PathBuf;
8use std::sync::{Arc, RwLock};
9
10use anyhow::Result as AnyhowResult;
11use rust_i18n::t;
12
13use crate::input::command_registry::CommandRegistry;
14use crate::input::commands::Suggestion;
15use crate::input::input_history::InputHistory;
16use crate::input::keybindings::KeyContext;
17use crate::input::quick_open::{BufferInfo, QuickOpenContext};
18use crate::services::async_bridge::AsyncMessage;
19use crate::services::plugins::PluginManager;
20use crate::view::prompt::{Prompt, PromptType};
21
22use super::file_open;
23use super::Editor;
24
25impl Editor {
26 pub fn start_prompt(&mut self, message: String, prompt_type: PromptType) {
30 self.start_prompt_with_suggestions(message, prompt_type, Vec::new());
31 }
32
33 pub(super) fn start_search_prompt(
38 &mut self,
39 message: String,
40 prompt_type: PromptType,
41 use_selection_range: bool,
42 ) {
43 self.pending_search_range = None;
45
46 let selection_range = self.active_cursors().primary().selection_range();
47
48 let selected_text = if let Some(range) = selection_range.clone() {
49 let state = self.active_state_mut();
50 let text = state.get_text_range(range.start, range.end);
51 if !text.contains('\n') && !text.is_empty() {
52 Some(text)
53 } else {
54 None
55 }
56 } else {
57 None
58 };
59
60 if use_selection_range {
61 self.pending_search_range = selection_range;
62 }
63
64 let from_history = selected_text.is_none();
66 let default_text = selected_text.or_else(|| {
67 self.get_prompt_history("search")
68 .and_then(|h| h.last().map(|s| s.to_string()))
69 });
70
71 self.start_prompt(message, prompt_type);
73
74 if let Some(text) = default_text {
76 if let Some(ref mut prompt) = self.prompt {
77 prompt.set_input(text.clone());
78 prompt.selection_anchor = Some(0);
79 prompt.cursor_pos = text.len();
80 }
81 if from_history {
82 self.get_or_create_prompt_history("search").init_at_last();
83 }
84 self.update_search_highlights(&text);
85 }
86 }
87
88 pub fn start_prompt_with_suggestions(
90 &mut self,
91 message: String,
92 prompt_type: PromptType,
93 suggestions: Vec<Suggestion>,
94 ) {
95 self.on_editor_focus_lost();
97
98 match prompt_type {
101 PromptType::Search | PromptType::ReplaceSearch | PromptType::QueryReplaceSearch => {
102 self.clear_search_highlights();
103 }
104 _ => {}
105 }
106
107 let needs_suggestions = matches!(
109 prompt_type,
110 PromptType::OpenFile | PromptType::SwitchProject | PromptType::SaveFileAs
111 );
112
113 self.prompt = Some(Prompt::with_suggestions(message, prompt_type, suggestions));
114
115 if needs_suggestions {
117 self.update_prompt_suggestions();
118 }
119 }
120
121 pub fn start_prompt_with_initial_text(
123 &mut self,
124 message: String,
125 prompt_type: PromptType,
126 initial_text: String,
127 ) {
128 self.on_editor_focus_lost();
130
131 self.prompt = Some(Prompt::with_initial_text(
132 message,
133 prompt_type,
134 initial_text,
135 ));
136 }
137
138 pub fn start_quick_open(&mut self) {
140 self.on_editor_focus_lost();
142
143 self.status_message = None;
145
146 let mut prompt = Prompt::with_suggestions(String::new(), PromptType::QuickOpen, vec![]);
148 prompt.input = ">".to_string();
149 prompt.cursor_pos = 1;
150 self.prompt = Some(prompt);
151
152 self.update_quick_open_suggestions(">");
154 }
155
156 pub(super) fn build_quick_open_context(&self) -> QuickOpenContext {
158 let open_buffers = self
159 .buffers
160 .iter()
161 .filter_map(|(buffer_id, state)| {
162 let path = state.buffer.file_path()?;
163 let name = path
164 .file_name()
165 .map(|n| n.to_string_lossy().to_string())
166 .unwrap_or_else(|| format!("Buffer {}", buffer_id.0));
167 Some(BufferInfo {
168 id: buffer_id.0,
169 path: path.display().to_string(),
170 name,
171 modified: state.buffer.is_modified(),
172 })
173 })
174 .collect();
175
176 let has_lsp_config = {
177 let language = self
178 .buffers
179 .get(&self.active_buffer())
180 .map(|s| s.language.as_str());
181 language
182 .and_then(|lang| self.lsp.as_ref().and_then(|lsp| lsp.get_config(lang)))
183 .is_some()
184 };
185
186 QuickOpenContext {
187 cwd: self.working_dir.display().to_string(),
188 open_buffers,
189 active_buffer_id: self.active_buffer().0,
190 active_buffer_path: self
191 .active_state()
192 .buffer
193 .file_path()
194 .map(|p| p.display().to_string()),
195 has_selection: self.has_active_selection(),
196 key_context: self.key_context.clone(),
197 custom_contexts: self.active_custom_contexts.clone(),
198 buffer_mode: self
199 .buffer_metadata
200 .get(&self.active_buffer())
201 .and_then(|m| m.virtual_mode())
202 .map(|s| s.to_string()),
203 has_lsp_config,
204 }
205 }
206
207 pub(super) fn update_quick_open_suggestions(&mut self, input: &str) {
209 let context = self.build_quick_open_context();
210 let suggestions = if let Some((provider, query)) =
211 self.quick_open_registry.get_provider_for_input(input)
212 {
213 provider.suggestions(query, &context)
214 } else {
215 vec![]
216 };
217
218 if let Some(prompt) = &mut self.prompt {
219 prompt.suggestions = suggestions;
220 prompt.selected_suggestion = if prompt.suggestions.is_empty() {
221 None
222 } else {
223 Some(0)
224 };
225 }
226 }
227
228 pub(super) fn cancel_search_prompt_if_active(&mut self) {
231 if let Some(ref prompt) = self.prompt {
232 if matches!(
233 prompt.prompt_type,
234 PromptType::Search
235 | PromptType::ReplaceSearch
236 | PromptType::Replace { .. }
237 | PromptType::QueryReplaceSearch
238 | PromptType::QueryReplace { .. }
239 | PromptType::QueryReplaceConfirm
240 ) {
241 self.prompt = None;
242 self.interactive_replace_state = None;
244 let ns = self.search_namespace.clone();
246 let state = self.active_state_mut();
247 state.overlays.clear_namespace(&ns, &mut state.marker_list);
248 }
249 }
250 }
251
252 pub(super) fn prefill_open_file_prompt(&mut self) {
254 if let Some(prompt) = self.prompt.as_mut() {
258 if prompt.prompt_type == PromptType::OpenFile {
259 prompt.input.clear();
260 prompt.cursor_pos = 0;
261 prompt.selection_anchor = None;
262 }
263 }
264 }
265
266 pub(super) fn init_file_open_state(&mut self) {
272 let buffer_id = self.active_buffer();
274
275 let initial_dir = if self.is_terminal_buffer(buffer_id) {
278 self.get_terminal_id(buffer_id)
279 .and_then(|tid| self.terminal_manager.get(tid))
280 .and_then(|handle| handle.cwd())
281 .unwrap_or_else(|| self.working_dir.clone())
282 } else {
283 self.active_state()
284 .buffer
285 .file_path()
286 .and_then(|path| path.parent())
287 .map(|p| p.to_path_buf())
288 .unwrap_or_else(|| self.working_dir.clone())
289 };
290
291 let show_hidden = self.config.file_browser.show_hidden;
293 self.file_open_state = Some(file_open::FileOpenState::new(
294 initial_dir.clone(),
295 show_hidden,
296 self.filesystem.clone(),
297 ));
298
299 self.load_file_open_directory(initial_dir);
301 self.load_file_open_shortcuts_async();
302 }
303
304 pub(super) fn init_folder_open_state(&mut self) {
309 let initial_dir = self.working_dir.clone();
311
312 let show_hidden = self.config.file_browser.show_hidden;
314 self.file_open_state = Some(file_open::FileOpenState::new(
315 initial_dir.clone(),
316 show_hidden,
317 self.filesystem.clone(),
318 ));
319
320 self.load_file_open_directory(initial_dir);
322 self.load_file_open_shortcuts_async();
323 }
324
325 pub fn change_working_dir(&mut self, new_path: PathBuf) {
335 let new_path = new_path.canonicalize().unwrap_or(new_path);
337
338 self.request_restart(new_path);
341 }
342
343 pub(super) fn load_file_open_directory(&mut self, path: PathBuf) {
345 if let Some(state) = &mut self.file_open_state {
347 state.current_dir = path.clone();
348 state.loading = true;
349 state.error = None;
350 state.update_shortcuts();
351 }
352
353 if let Some(ref runtime) = self.tokio_runtime {
355 let fs_manager = self.fs_manager.clone();
356 let sender = self.async_bridge.as_ref().map(|b| b.sender());
357
358 runtime.spawn(async move {
359 let result = fs_manager.list_dir_with_metadata(path).await;
360 if let Some(sender) = sender {
361 #[allow(clippy::let_underscore_must_use)]
363 let _ = sender.send(AsyncMessage::FileOpenDirectoryLoaded(result));
364 }
365 });
366 } else {
367 if let Some(state) = &mut self.file_open_state {
369 state.set_error("Async runtime not available".to_string());
370 }
371 }
372 }
373
374 pub(super) fn handle_file_open_directory_loaded(
376 &mut self,
377 result: std::io::Result<Vec<crate::services::fs::DirEntry>>,
378 ) {
379 match result {
380 Ok(entries) => {
381 if let Some(state) = &mut self.file_open_state {
382 state.set_entries(entries);
383 }
384 let filter = self
386 .prompt
387 .as_ref()
388 .map(|p| p.input.clone())
389 .unwrap_or_default();
390 if !filter.is_empty() {
391 if let Some(state) = &mut self.file_open_state {
392 state.apply_filter(&filter);
393 }
394 }
395 }
396 Err(e) => {
397 if let Some(state) = &mut self.file_open_state {
398 state.set_error(e.to_string());
399 }
400 }
401 }
402 }
403
404 pub(super) fn load_file_open_shortcuts_async(&mut self) {
408 if let Some(ref runtime) = self.tokio_runtime {
409 let filesystem = self.filesystem.clone();
410 let sender = self.async_bridge.as_ref().map(|b| b.sender());
411
412 runtime.spawn(async move {
413 let shortcuts = tokio::task::spawn_blocking(move || {
415 file_open::FileOpenState::build_shortcuts_async(&*filesystem)
416 })
417 .await
418 .unwrap_or_default();
419
420 if let Some(sender) = sender {
421 #[allow(clippy::let_underscore_must_use)]
423 let _ = sender.send(AsyncMessage::FileOpenShortcutsLoaded(shortcuts));
424 }
425 });
426 }
427 }
428
429 pub(super) fn handle_file_open_shortcuts_loaded(
431 &mut self,
432 shortcuts: Vec<file_open::NavigationShortcut>,
433 ) {
434 if let Some(state) = &mut self.file_open_state {
435 state.merge_async_shortcuts(shortcuts);
436 }
437 }
438
439 pub fn cancel_prompt(&mut self) {
441 let theme_to_restore = if let Some(ref prompt) = self.prompt {
443 if let PromptType::SelectTheme { original_theme } = &prompt.prompt_type {
444 Some(original_theme.clone())
445 } else {
446 None
447 }
448 } else {
449 None
450 };
451
452 if let Some(ref prompt) = self.prompt {
454 if let Some(key) = Self::prompt_type_to_history_key(&prompt.prompt_type) {
456 if let Some(history) = self.prompt_histories.get_mut(&key) {
457 history.reset_navigation();
458 }
459 }
460 match &prompt.prompt_type {
461 PromptType::Search | PromptType::ReplaceSearch | PromptType::QueryReplaceSearch => {
462 self.clear_search_highlights();
463 }
464 PromptType::Plugin { custom_type } => {
465 use crate::services::plugins::hooks::HookArgs;
467 self.plugin_manager.run_hook(
468 "prompt_cancelled",
469 HookArgs::PromptCancelled {
470 prompt_type: custom_type.clone(),
471 input: prompt.input.clone(),
472 },
473 );
474 }
475 PromptType::LspRename { overlay_handle, .. } => {
476 let remove_overlay_event = crate::model::event::Event::RemoveOverlay {
478 handle: overlay_handle.clone(),
479 };
480 self.apply_event_to_active_buffer(&remove_overlay_event);
481 }
482 PromptType::OpenFile | PromptType::SwitchProject | PromptType::SaveFileAs => {
483 self.file_open_state = None;
485 self.file_browser_layout = None;
486 }
487 PromptType::AsyncPrompt => {
488 if let Some(callback_id) = self.pending_async_prompt_callback.take() {
490 self.plugin_manager
491 .resolve_callback(callback_id, "null".to_string());
492 }
493 }
494 PromptType::QuickOpen => {
495 if let Some((provider, _)) = self.quick_open_registry.get_provider_for_input("")
497 {
498 if let Some(fp) = provider
499 .as_any()
500 .downcast_ref::<crate::input::quick_open::providers::FileProvider>(
501 ) {
502 fp.cancel_loading();
503 }
504 }
505 }
506 _ => {}
507 }
508 }
509
510 self.prompt = None;
511 self.pending_search_range = None;
512 self.status_message = Some(t!("search.cancelled").to_string());
513
514 if let Some(original_theme) = theme_to_restore {
516 self.preview_theme(&original_theme);
517 }
518 }
519
520 pub fn handle_prompt_scroll(&mut self, delta: i32) -> bool {
523 if let Some(ref mut prompt) = self.prompt {
524 if prompt.suggestions.is_empty() {
525 return false;
526 }
527
528 let current = prompt.selected_suggestion.unwrap_or(0);
529 let len = prompt.suggestions.len();
530
531 let new_selected = if delta < 0 {
534 current.saturating_sub((-delta) as usize)
536 } else {
537 (current + delta as usize).min(len.saturating_sub(1))
539 };
540
541 prompt.selected_suggestion = Some(new_selected);
542
543 if !matches!(prompt.prompt_type, PromptType::Plugin { .. }) {
545 if let Some(suggestion) = prompt.suggestions.get(new_selected) {
546 prompt.input = suggestion.get_value().to_string();
547 prompt.cursor_pos = prompt.input.len();
548 }
549 }
550
551 return true;
552 }
553 false
554 }
555
556 pub fn confirm_prompt(&mut self) -> Option<(String, PromptType, Option<usize>)> {
561 if let Some(prompt) = self.prompt.take() {
562 let selected_index = prompt.selected_suggestion;
563 let mut final_input = if prompt.sync_input_on_navigate {
565 prompt.input.clone()
568 } else if matches!(
569 prompt.prompt_type,
570 PromptType::OpenFile
571 | PromptType::SwitchProject
572 | PromptType::SaveFileAs
573 | PromptType::StopLspServer
574 | PromptType::RestartLspServer
575 | PromptType::SelectTheme { .. }
576 | PromptType::SelectLocale
577 | PromptType::SwitchToTab
578 | PromptType::SetLanguage
579 | PromptType::SetEncoding
580 | PromptType::SetLineEnding
581 | PromptType::Plugin { .. }
582 ) {
583 if let Some(selected_idx) = prompt.selected_suggestion {
585 if let Some(suggestion) = prompt.suggestions.get(selected_idx) {
586 if suggestion.disabled {
588 self.set_status_message(
589 t!(
590 "error.command_not_available",
591 command = suggestion.text.clone()
592 )
593 .to_string(),
594 );
595 return None;
596 }
597 suggestion.get_value().to_string()
599 } else {
600 prompt.input.clone()
601 }
602 } else {
603 prompt.input.clone()
604 }
605 } else {
606 prompt.input.clone()
607 };
608
609 if matches!(
611 prompt.prompt_type,
612 PromptType::StopLspServer | PromptType::RestartLspServer
613 ) {
614 let is_valid = prompt
615 .suggestions
616 .iter()
617 .any(|s| s.text == final_input || s.get_value() == final_input);
618 if !is_valid {
619 self.prompt = Some(prompt);
621 self.set_status_message(
622 t!("error.no_lsp_match", input = final_input.clone()).to_string(),
623 );
624 return None;
625 }
626 }
627
628 if matches!(prompt.prompt_type, PromptType::RemoveRuler) {
632 if prompt.input.is_empty() {
633 if let Some(selected_idx) = prompt.selected_suggestion {
635 if let Some(suggestion) = prompt.suggestions.get(selected_idx) {
636 final_input = suggestion.get_value().to_string();
637 }
638 } else {
639 self.prompt = Some(prompt);
640 return None;
641 }
642 } else {
643 let typed = prompt.input.trim().to_string();
645 let matched = prompt.suggestions.iter().find(|s| s.get_value() == typed);
646 if let Some(suggestion) = matched {
647 final_input = suggestion.get_value().to_string();
648 } else {
649 self.prompt = Some(prompt);
651 return None;
652 }
653 }
654 }
655
656 if let Some(key) = Self::prompt_type_to_history_key(&prompt.prompt_type) {
658 let history = self.get_or_create_prompt_history(&key);
659 history.push(final_input.clone());
660 history.reset_navigation();
661 }
662
663 Some((final_input, prompt.prompt_type, selected_index))
664 } else {
665 None
666 }
667 }
668
669 pub fn is_prompting(&self) -> bool {
671 self.prompt.is_some()
672 }
673
674 pub(super) fn get_or_create_prompt_history(
676 &mut self,
677 key: &str,
678 ) -> &mut crate::input::input_history::InputHistory {
679 self.prompt_histories.entry(key.to_string()).or_default()
680 }
681
682 pub(super) fn get_prompt_history(
684 &self,
685 key: &str,
686 ) -> Option<&crate::input::input_history::InputHistory> {
687 self.prompt_histories.get(key)
688 }
689
690 pub(super) fn prompt_type_to_history_key(
692 prompt_type: &crate::view::prompt::PromptType,
693 ) -> Option<String> {
694 use crate::view::prompt::PromptType;
695 match prompt_type {
696 PromptType::Search | PromptType::ReplaceSearch | PromptType::QueryReplaceSearch => {
697 Some("search".to_string())
698 }
699 PromptType::Replace { .. } | PromptType::QueryReplace { .. } => {
700 Some("replace".to_string())
701 }
702 PromptType::GotoLine => Some("goto_line".to_string()),
703 PromptType::Plugin { custom_type } => Some(format!("plugin:{}", custom_type)),
704 _ => None,
705 }
706 }
707
708 pub fn editor_mode(&self) -> Option<String> {
711 self.editor_mode.clone()
712 }
713
714 pub fn command_registry(&self) -> &Arc<RwLock<CommandRegistry>> {
716 &self.command_registry
717 }
718
719 pub fn plugin_manager(&self) -> &PluginManager {
721 &self.plugin_manager
722 }
723
724 pub fn plugin_manager_mut(&mut self) -> &mut PluginManager {
726 &mut self.plugin_manager
727 }
728
729 pub fn file_explorer_is_focused(&self) -> bool {
731 self.key_context == KeyContext::FileExplorer
732 }
733
734 pub fn prompt_input(&self) -> Option<&str> {
736 self.prompt.as_ref().map(|p| p.input.as_str())
737 }
738
739 pub fn has_active_selection(&self) -> bool {
741 self.active_cursors().primary().selection_range().is_some()
742 }
743
744 pub fn prompt_mut(&mut self) -> Option<&mut Prompt> {
746 self.prompt.as_mut()
747 }
748
749 pub fn set_status_message(&mut self, message: String) {
751 tracing::info!(target: "status", "{}", message);
752 self.plugin_status_message = None;
753 self.status_message = Some(message);
754 }
755
756 pub fn get_status_message(&self) -> Option<&String> {
758 self.plugin_status_message
759 .as_ref()
760 .or(self.status_message.as_ref())
761 }
762
763 pub fn get_plugin_errors(&self) -> &[String] {
766 &self.plugin_errors
767 }
768
769 pub fn clear_plugin_errors(&mut self) {
771 self.plugin_errors.clear();
772 }
773
774 pub fn update_prompt_suggestions(&mut self) {
776 let (prompt_type, input) = if let Some(prompt) = &self.prompt {
778 (prompt.prompt_type.clone(), prompt.input.clone())
779 } else {
780 return;
781 };
782
783 match prompt_type {
784 PromptType::QuickOpen => {
785 self.update_quick_open_suggestions(&input);
787 }
788 PromptType::Search | PromptType::ReplaceSearch | PromptType::QueryReplaceSearch => {
789 self.update_search_highlights(&input);
791 if let Some(history) = self.prompt_histories.get_mut("search") {
793 history.reset_navigation();
794 }
795 }
796 PromptType::Replace { .. } | PromptType::QueryReplace { .. } => {
797 if let Some(history) = self.prompt_histories.get_mut("replace") {
799 history.reset_navigation();
800 }
801 }
802 PromptType::GotoLine => {
803 if let Some(history) = self.prompt_histories.get_mut("goto_line") {
805 history.reset_navigation();
806 }
807 }
808 PromptType::OpenFile | PromptType::SwitchProject | PromptType::SaveFileAs => {
809 self.update_file_open_filter();
811 }
812 PromptType::Plugin { custom_type } => {
813 let key = format!("plugin:{}", custom_type);
815 if let Some(history) = self.prompt_histories.get_mut(&key) {
816 history.reset_navigation();
817 }
818 use crate::services::plugins::hooks::HookArgs;
820 self.plugin_manager.run_hook(
821 "prompt_changed",
822 HookArgs::PromptChanged {
823 prompt_type: custom_type,
824 input,
825 },
826 );
827 if let Some(prompt) = &mut self.prompt {
832 prompt.filter_suggestions(false);
833 }
834 }
835 PromptType::SwitchToTab
836 | PromptType::SelectTheme { .. }
837 | PromptType::StopLspServer
838 | PromptType::RestartLspServer
839 | PromptType::SetLanguage
840 | PromptType::SetEncoding
841 | PromptType::SetLineEnding => {
842 if let Some(prompt) = &mut self.prompt {
843 prompt.filter_suggestions(false);
844 }
845 }
846 PromptType::SelectLocale => {
847 if let Some(prompt) = &mut self.prompt {
849 prompt.filter_suggestions(true);
850 }
851 }
852 _ => {}
853 }
854 }
855}