1use 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 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 pub(super) fn start_search_prompt(
36 &mut self,
37 message: String,
38 prompt_type: PromptType,
39 use_selection_range: bool,
40 ) {
41 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 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 self.start_prompt(message, prompt_type);
71
72 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 pub fn start_prompt_with_suggestions(
88 &mut self,
89 message: String,
90 prompt_type: PromptType,
91 suggestions: Vec<Suggestion>,
92 ) {
93 self.on_editor_focus_lost();
95
96 match prompt_type {
99 PromptType::Search | PromptType::ReplaceSearch | PromptType::QueryReplaceSearch => {
100 self.clear_search_highlights();
101 }
102 _ => {}
103 }
104
105 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 if needs_suggestions {
115 self.update_prompt_suggestions();
116 }
117 }
118
119 pub fn start_prompt_with_initial_text(
121 &mut self,
122 message: String,
123 prompt_type: PromptType,
124 initial_text: String,
125 ) {
126 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 pub fn start_quick_open(&mut self) {
138 self.on_editor_focus_lost();
140
141 self.status_message = None;
143
144 let mut prompt = Prompt::with_suggestions(String::new(), PromptType::QuickOpen, vec![]);
146 prompt.input = ">".to_string();
147 prompt.cursor_pos = 1;
148 self.prompt = Some(prompt);
149
150 self.update_quick_open_suggestions(">");
152 }
153
154 pub(super) fn build_quick_open_context(&self) -> QuickOpenContext {
156 let open_buffers = self
157 .buffers
158 .iter()
159 .filter_map(|(buffer_id, state)| {
160 let path = state.buffer.file_path()?;
161 let name = path
162 .file_name()
163 .map(|n| n.to_string_lossy().to_string())
164 .unwrap_or_else(|| format!("Buffer {}", buffer_id.0));
165 Some(BufferInfo {
166 id: buffer_id.0,
167 path: path.display().to_string(),
168 name,
169 modified: state.buffer.is_modified(),
170 })
171 })
172 .collect();
173
174 let has_lsp_config = {
175 let language = self
176 .buffers
177 .get(&self.active_buffer())
178 .map(|s| s.language.as_str());
179 language
180 .and_then(|lang| self.lsp.as_ref().and_then(|lsp| lsp.get_config(lang)))
181 .is_some()
182 };
183
184 QuickOpenContext {
185 cwd: self.working_dir.display().to_string(),
186 open_buffers,
187 active_buffer_id: self.active_buffer().0,
188 active_buffer_path: self
189 .active_state()
190 .buffer
191 .file_path()
192 .map(|p| p.display().to_string()),
193 has_selection: self.has_active_selection(),
194 key_context: self.key_context.clone(),
195 custom_contexts: self.active_custom_contexts.clone(),
196 buffer_mode: self
197 .buffer_metadata
198 .get(&self.active_buffer())
199 .and_then(|m| m.virtual_mode())
200 .map(|s| s.to_string()),
201 has_lsp_config,
202 }
203 }
204
205 pub(super) fn update_quick_open_suggestions(&mut self, input: &str) {
207 let context = self.build_quick_open_context();
208 let suggestions = if let Some((provider, query)) =
209 self.quick_open_registry.get_provider_for_input(input)
210 {
211 provider.suggestions(query, &context)
212 } else {
213 vec![]
214 };
215
216 if let Some(prompt) = &mut self.prompt {
217 prompt.suggestions = suggestions;
218 prompt.selected_suggestion = if prompt.suggestions.is_empty() {
219 None
220 } else {
221 Some(0)
222 };
223 }
224 }
225
226 pub(super) fn cancel_search_prompt_if_active(&mut self) {
229 if let Some(ref prompt) = self.prompt {
230 if matches!(
231 prompt.prompt_type,
232 PromptType::Search
233 | PromptType::ReplaceSearch
234 | PromptType::Replace { .. }
235 | PromptType::QueryReplaceSearch
236 | PromptType::QueryReplace { .. }
237 | PromptType::QueryReplaceConfirm
238 ) {
239 self.prompt = None;
240 self.interactive_replace_state = None;
242 let ns = self.search_namespace.clone();
244 let state = self.active_state_mut();
245 state.overlays.clear_namespace(&ns, &mut state.marker_list);
246 }
247 }
248 }
249
250 pub(super) fn prefill_open_file_prompt(&mut self) {
252 if let Some(prompt) = self.prompt.as_mut() {
256 if prompt.prompt_type == PromptType::OpenFile {
257 prompt.input.clear();
258 prompt.cursor_pos = 0;
259 prompt.selection_anchor = None;
260 }
261 }
262 }
263
264 pub(super) fn init_file_open_state(&mut self) {
270 let buffer_id = self.active_buffer();
272
273 let initial_dir = if self.is_terminal_buffer(buffer_id) {
276 self.get_terminal_id(buffer_id)
277 .and_then(|tid| self.terminal_manager.get(tid))
278 .and_then(|handle| handle.cwd())
279 .unwrap_or_else(|| self.working_dir.clone())
280 } else {
281 self.active_state()
282 .buffer
283 .file_path()
284 .and_then(|path| path.parent())
285 .map(|p| p.to_path_buf())
286 .unwrap_or_else(|| self.working_dir.clone())
287 };
288
289 let show_hidden = self.config.file_browser.show_hidden;
291 self.file_open_state = Some(file_open::FileOpenState::new(
292 initial_dir.clone(),
293 show_hidden,
294 self.filesystem.clone(),
295 ));
296
297 self.load_file_open_directory(initial_dir);
299 self.load_file_open_shortcuts_async();
300 }
301
302 pub(super) fn init_folder_open_state(&mut self) {
307 let initial_dir = self.working_dir.clone();
309
310 let show_hidden = self.config.file_browser.show_hidden;
312 self.file_open_state = Some(file_open::FileOpenState::new(
313 initial_dir.clone(),
314 show_hidden,
315 self.filesystem.clone(),
316 ));
317
318 self.load_file_open_directory(initial_dir);
320 self.load_file_open_shortcuts_async();
321 }
322
323 pub fn change_working_dir(&mut self, new_path: PathBuf) {
333 let new_path = new_path.canonicalize().unwrap_or(new_path);
335
336 self.request_restart(new_path);
339 }
340
341 pub(super) fn load_file_open_directory(&mut self, path: PathBuf) {
343 if let Some(state) = &mut self.file_open_state {
345 state.current_dir = path.clone();
346 state.loading = true;
347 state.error = None;
348 state.update_shortcuts();
349 }
350
351 if let Some(ref runtime) = self.tokio_runtime {
353 let fs_manager = self.fs_manager.clone();
354 let sender = self.async_bridge.as_ref().map(|b| b.sender());
355
356 runtime.spawn(async move {
357 let result = fs_manager.list_dir_with_metadata(path).await;
358 if let Some(sender) = sender {
359 #[allow(clippy::let_underscore_must_use)]
361 let _ = sender.send(AsyncMessage::FileOpenDirectoryLoaded(result));
362 }
363 });
364 } else {
365 if let Some(state) = &mut self.file_open_state {
367 state.set_error("Async runtime not available".to_string());
368 }
369 }
370 }
371
372 pub(super) fn handle_file_open_directory_loaded(
374 &mut self,
375 result: std::io::Result<Vec<crate::services::fs::DirEntry>>,
376 ) {
377 match result {
378 Ok(entries) => {
379 if let Some(state) = &mut self.file_open_state {
380 state.set_entries(entries);
381 }
382 let filter = self
384 .prompt
385 .as_ref()
386 .map(|p| p.input.clone())
387 .unwrap_or_default();
388 if !filter.is_empty() {
389 if let Some(state) = &mut self.file_open_state {
390 state.apply_filter(&filter);
391 }
392 }
393 }
394 Err(e) => {
395 if let Some(state) = &mut self.file_open_state {
396 state.set_error(e.to_string());
397 }
398 }
399 }
400 }
401
402 pub(super) fn load_file_open_shortcuts_async(&mut self) {
406 if let Some(ref runtime) = self.tokio_runtime {
407 let filesystem = self.filesystem.clone();
408 let sender = self.async_bridge.as_ref().map(|b| b.sender());
409
410 runtime.spawn(async move {
411 let shortcuts = tokio::task::spawn_blocking(move || {
413 file_open::FileOpenState::build_shortcuts_async(&*filesystem)
414 })
415 .await
416 .unwrap_or_default();
417
418 if let Some(sender) = sender {
419 #[allow(clippy::let_underscore_must_use)]
421 let _ = sender.send(AsyncMessage::FileOpenShortcutsLoaded(shortcuts));
422 }
423 });
424 }
425 }
426
427 pub(super) fn handle_file_open_shortcuts_loaded(
429 &mut self,
430 shortcuts: Vec<file_open::NavigationShortcut>,
431 ) {
432 if let Some(state) = &mut self.file_open_state {
433 state.merge_async_shortcuts(shortcuts);
434 }
435 }
436
437 pub fn cancel_prompt(&mut self) {
439 let theme_to_restore = if let Some(ref prompt) = self.prompt {
441 if let PromptType::SelectTheme { original_theme } = &prompt.prompt_type {
442 Some(original_theme.clone())
443 } else {
444 None
445 }
446 } else {
447 None
448 };
449
450 if let Some(ref prompt) = self.prompt {
452 if let Some(key) = Self::prompt_type_to_history_key(&prompt.prompt_type) {
454 if let Some(history) = self.prompt_histories.get_mut(&key) {
455 history.reset_navigation();
456 }
457 }
458 match &prompt.prompt_type {
459 PromptType::Search | PromptType::ReplaceSearch | PromptType::QueryReplaceSearch => {
460 self.clear_search_highlights();
461 }
462 PromptType::Plugin { custom_type } => {
463 use crate::services::plugins::hooks::HookArgs;
465 self.plugin_manager.run_hook(
466 "prompt_cancelled",
467 HookArgs::PromptCancelled {
468 prompt_type: custom_type.clone(),
469 input: prompt.input.clone(),
470 },
471 );
472 }
473 PromptType::LspRename { overlay_handle, .. } => {
474 let remove_overlay_event = crate::model::event::Event::RemoveOverlay {
476 handle: overlay_handle.clone(),
477 };
478 self.apply_event_to_active_buffer(&remove_overlay_event);
479 }
480 PromptType::OpenFile | PromptType::SwitchProject | PromptType::SaveFileAs => {
481 self.file_open_state = None;
483 self.file_browser_layout = None;
484 }
485 PromptType::AsyncPrompt => {
486 if let Some(callback_id) = self.pending_async_prompt_callback.take() {
488 self.plugin_manager
489 .resolve_callback(callback_id, "null".to_string());
490 }
491 }
492 PromptType::QuickOpen => {
493 if let Some((provider, _)) = self.quick_open_registry.get_provider_for_input("")
495 {
496 if let Some(fp) = provider
497 .as_any()
498 .downcast_ref::<crate::input::quick_open::providers::FileProvider>(
499 ) {
500 fp.cancel_loading();
501 }
502 }
503 }
504 _ => {}
505 }
506 }
507
508 self.prompt = None;
509 self.pending_search_range = None;
510 self.status_message = Some(t!("search.cancelled").to_string());
511
512 if let Some(original_theme) = theme_to_restore {
514 self.preview_theme(&original_theme);
515 }
516 }
517
518 pub fn handle_prompt_scroll(&mut self, delta: i32) -> bool {
521 if let Some(ref mut prompt) = self.prompt {
522 if prompt.suggestions.is_empty() {
523 return false;
524 }
525
526 let current = prompt.selected_suggestion.unwrap_or(0);
527 let len = prompt.suggestions.len();
528
529 let new_selected = if delta < 0 {
532 current.saturating_sub((-delta) as usize)
534 } else {
535 (current + delta as usize).min(len.saturating_sub(1))
537 };
538
539 prompt.selected_suggestion = Some(new_selected);
540
541 if !matches!(prompt.prompt_type, PromptType::Plugin { .. }) {
543 if let Some(suggestion) = prompt.suggestions.get(new_selected) {
544 prompt.input = suggestion.get_value().to_string();
545 prompt.cursor_pos = prompt.input.len();
546 }
547 }
548
549 return true;
550 }
551 false
552 }
553
554 pub fn confirm_prompt(&mut self) -> Option<(String, PromptType, Option<usize>)> {
559 if let Some(prompt) = self.prompt.take() {
560 let selected_index = prompt.selected_suggestion;
561 let mut final_input = if prompt.sync_input_on_navigate {
563 prompt.input.clone()
566 } else if matches!(
567 prompt.prompt_type,
568 PromptType::OpenFile
569 | PromptType::SwitchProject
570 | PromptType::SaveFileAs
571 | PromptType::StopLspServer
572 | PromptType::RestartLspServer
573 | PromptType::SelectTheme { .. }
574 | PromptType::SelectLocale
575 | PromptType::SwitchToTab
576 | PromptType::SetLanguage
577 | PromptType::SetEncoding
578 | PromptType::SetLineEnding
579 | PromptType::Plugin { .. }
580 ) {
581 if let Some(selected_idx) = prompt.selected_suggestion {
583 if let Some(suggestion) = prompt.suggestions.get(selected_idx) {
584 if suggestion.disabled {
586 self.set_status_message(
587 t!(
588 "error.command_not_available",
589 command = suggestion.text.clone()
590 )
591 .to_string(),
592 );
593 return None;
594 }
595 suggestion.get_value().to_string()
597 } else {
598 prompt.input.clone()
599 }
600 } else {
601 prompt.input.clone()
602 }
603 } else {
604 prompt.input.clone()
605 };
606
607 if matches!(
609 prompt.prompt_type,
610 PromptType::StopLspServer | PromptType::RestartLspServer
611 ) {
612 let is_valid = prompt
613 .suggestions
614 .iter()
615 .any(|s| s.text == final_input || s.get_value() == final_input);
616 if !is_valid {
617 self.prompt = Some(prompt);
619 self.set_status_message(
620 t!("error.no_lsp_match", input = final_input.clone()).to_string(),
621 );
622 return None;
623 }
624 }
625
626 if matches!(prompt.prompt_type, PromptType::RemoveRuler) {
630 if prompt.input.is_empty() {
631 if let Some(selected_idx) = prompt.selected_suggestion {
633 if let Some(suggestion) = prompt.suggestions.get(selected_idx) {
634 final_input = suggestion.get_value().to_string();
635 }
636 } else {
637 self.prompt = Some(prompt);
638 return None;
639 }
640 } else {
641 let typed = prompt.input.trim().to_string();
643 let matched = prompt.suggestions.iter().find(|s| s.get_value() == typed);
644 if let Some(suggestion) = matched {
645 final_input = suggestion.get_value().to_string();
646 } else {
647 self.prompt = Some(prompt);
649 return None;
650 }
651 }
652 }
653
654 if let Some(key) = Self::prompt_type_to_history_key(&prompt.prompt_type) {
656 let history = self.get_or_create_prompt_history(&key);
657 history.push(final_input.clone());
658 history.reset_navigation();
659 }
660
661 Some((final_input, prompt.prompt_type, selected_index))
662 } else {
663 None
664 }
665 }
666
667 pub fn is_prompting(&self) -> bool {
669 self.prompt.is_some()
670 }
671
672 pub(super) fn get_or_create_prompt_history(
674 &mut self,
675 key: &str,
676 ) -> &mut crate::input::input_history::InputHistory {
677 self.prompt_histories.entry(key.to_string()).or_default()
678 }
679
680 pub(super) fn get_prompt_history(
682 &self,
683 key: &str,
684 ) -> Option<&crate::input::input_history::InputHistory> {
685 self.prompt_histories.get(key)
686 }
687
688 pub(super) fn prompt_type_to_history_key(
690 prompt_type: &crate::view::prompt::PromptType,
691 ) -> Option<String> {
692 use crate::view::prompt::PromptType;
693 match prompt_type {
694 PromptType::Search | PromptType::ReplaceSearch | PromptType::QueryReplaceSearch => {
695 Some("search".to_string())
696 }
697 PromptType::Replace { .. } | PromptType::QueryReplace { .. } => {
698 Some("replace".to_string())
699 }
700 PromptType::GotoLine => Some("goto_line".to_string()),
701 PromptType::Plugin { custom_type } => Some(format!("plugin:{}", custom_type)),
702 _ => None,
703 }
704 }
705
706 pub fn editor_mode(&self) -> Option<String> {
709 self.editor_mode.clone()
710 }
711
712 pub fn command_registry(&self) -> &Arc<RwLock<CommandRegistry>> {
714 &self.command_registry
715 }
716
717 pub fn plugin_manager(&self) -> &PluginManager {
719 &self.plugin_manager
720 }
721
722 pub fn plugin_manager_mut(&mut self) -> &mut PluginManager {
724 &mut self.plugin_manager
725 }
726
727 pub fn file_explorer_is_focused(&self) -> bool {
729 self.key_context == KeyContext::FileExplorer
730 }
731
732 pub fn prompt_input(&self) -> Option<&str> {
734 self.prompt.as_ref().map(|p| p.input.as_str())
735 }
736
737 pub fn has_active_selection(&self) -> bool {
739 self.active_cursors().primary().selection_range().is_some()
740 }
741
742 pub fn prompt_mut(&mut self) -> Option<&mut Prompt> {
744 self.prompt.as_mut()
745 }
746
747 pub fn set_status_message(&mut self, message: String) {
749 tracing::info!(target: "status", "{}", message);
750 self.plugin_status_message = None;
751 self.status_message = Some(message);
752 }
753
754 pub fn get_status_message(&self) -> Option<&String> {
756 self.plugin_status_message
757 .as_ref()
758 .or(self.status_message.as_ref())
759 }
760
761 pub fn get_plugin_errors(&self) -> &[String] {
764 &self.plugin_errors
765 }
766
767 pub fn clear_plugin_errors(&mut self) {
769 self.plugin_errors.clear();
770 }
771
772 pub fn update_prompt_suggestions(&mut self) {
774 let (prompt_type, input) = if let Some(prompt) = &self.prompt {
776 (prompt.prompt_type.clone(), prompt.input.clone())
777 } else {
778 return;
779 };
780
781 match prompt_type {
782 PromptType::QuickOpen => {
783 self.update_quick_open_suggestions(&input);
785 }
786 PromptType::Search | PromptType::ReplaceSearch | PromptType::QueryReplaceSearch => {
787 self.update_search_highlights(&input);
789 if let Some(history) = self.prompt_histories.get_mut("search") {
791 history.reset_navigation();
792 }
793 }
794 PromptType::Replace { .. } | PromptType::QueryReplace { .. } => {
795 if let Some(history) = self.prompt_histories.get_mut("replace") {
797 history.reset_navigation();
798 }
799 }
800 PromptType::GotoLine => {
801 if let Some(history) = self.prompt_histories.get_mut("goto_line") {
803 history.reset_navigation();
804 }
805 }
806 PromptType::OpenFile | PromptType::SwitchProject | PromptType::SaveFileAs => {
807 self.update_file_open_filter();
809 }
810 PromptType::Plugin { custom_type } => {
811 let key = format!("plugin:{}", custom_type);
813 if let Some(history) = self.prompt_histories.get_mut(&key) {
814 history.reset_navigation();
815 }
816 use crate::services::plugins::hooks::HookArgs;
818 self.plugin_manager.run_hook(
819 "prompt_changed",
820 HookArgs::PromptChanged {
821 prompt_type: custom_type,
822 input,
823 },
824 );
825 if let Some(prompt) = &mut self.prompt {
830 prompt.filter_suggestions(false);
831 }
832 }
833 PromptType::SwitchToTab
834 | PromptType::SelectTheme { .. }
835 | PromptType::StopLspServer
836 | PromptType::RestartLspServer
837 | PromptType::SetLanguage
838 | PromptType::SetEncoding
839 | PromptType::SetLineEnding => {
840 if let Some(prompt) = &mut self.prompt {
841 prompt.filter_suggestions(false);
842 }
843 }
844 PromptType::SelectLocale => {
845 if let Some(prompt) = &mut self.prompt {
847 prompt.filter_suggestions(true);
848 }
849 }
850 _ => {}
851 }
852 }
853}