1use rust_i18n::t;
6
7use super::normalize_path;
8use super::BufferId;
9use super::BufferMetadata;
10use super::Editor;
11use crate::config_io::{ConfigLayer, ConfigResolver};
12use crate::input::keybindings::Action;
13use crate::primitives::path_utils::expand_tilde;
14use crate::services::plugins::hooks::HookArgs;
15use crate::view::prompt::PromptType;
16
17pub enum PromptResult {
19 Done,
21 ExecuteAction(Action),
23 EarlyReturn,
25}
26
27pub(super) fn parse_path_line_col(input: &str) -> (String, Option<usize>, Option<usize>) {
28 use std::path::{Component, Path};
29
30 let trimmed = input.trim();
31 if trimmed.is_empty() {
32 return (String::new(), None, None);
33 }
34
35 let has_prefix = Path::new(trimmed)
37 .components()
38 .next()
39 .map(|c| matches!(c, Component::Prefix(_)))
40 .unwrap_or(false);
41
42 let search_start = if has_prefix {
44 trimmed.find(':').map(|i| i + 1).unwrap_or(0)
45 } else {
46 0
47 };
48
49 let suffix = &trimmed[search_start..];
50 let parts: Vec<&str> = suffix.rsplitn(3, ':').collect();
51
52 match parts.as_slice() {
53 [maybe_col, maybe_line, rest] => {
54 if !rest.is_empty() {
55 if let (Ok(line), Ok(col)) =
56 (maybe_line.parse::<usize>(), maybe_col.parse::<usize>())
57 {
58 if line > 0 && col > 0 {
59 let path_str = if has_prefix {
60 format!("{}{}", &trimmed[..search_start], rest)
61 } else {
62 rest.to_string()
63 };
64 return (path_str, Some(line), Some(col));
65 }
66 }
67 }
68 }
69 [maybe_line, rest] => {
70 if !rest.is_empty() {
71 if let Ok(line) = maybe_line.parse::<usize>() {
72 if line > 0 {
73 let path_str = if has_prefix {
74 format!("{}{}", &trimmed[..search_start], rest)
75 } else {
76 rest.to_string()
77 };
78 return (path_str, Some(line), None);
79 }
80 }
81 }
82 }
83 _ => {}
84 }
85
86 (trimmed.to_string(), None, None)
87}
88
89impl Editor {
90 pub fn handle_prompt_confirm_input(
94 &mut self,
95 input: String,
96 prompt_type: PromptType,
97 selected_index: Option<usize>,
98 ) -> PromptResult {
99 match prompt_type {
100 PromptType::OpenFile => {
101 let (path_str, line, column) = parse_path_line_col(&input);
102 let expanded_path = expand_tilde(&path_str);
104 let resolved_path = if expanded_path.is_absolute() {
105 normalize_path(&expanded_path)
106 } else {
107 normalize_path(&self.working_dir.join(&expanded_path))
108 };
109
110 self.open_file_with_jump(resolved_path, line, column);
111 }
112 PromptType::OpenFileWithEncoding { path } => {
113 self.handle_open_file_with_encoding(&path, &input);
114 }
115 PromptType::ReloadWithEncoding => {
116 self.handle_reload_with_encoding(&input);
117 }
118 PromptType::SwitchProject => {
119 let expanded_path = expand_tilde(&input);
121 let resolved_path = if expanded_path.is_absolute() {
122 normalize_path(&expanded_path)
123 } else {
124 normalize_path(&self.working_dir.join(&expanded_path))
125 };
126
127 if resolved_path.is_dir() {
128 self.change_working_dir(resolved_path);
129 } else {
130 self.set_status_message(
131 t!(
132 "file.not_directory",
133 path = resolved_path.display().to_string()
134 )
135 .to_string(),
136 );
137 }
138 }
139 PromptType::SaveFileAs => {
140 self.handle_save_file_as(&input);
141 }
142 PromptType::Search => {
143 self.perform_search(&input);
144 }
145 PromptType::ReplaceSearch => {
146 self.perform_search(&input);
147 self.start_prompt(
148 t!("replace.prompt", search = &input).to_string(),
149 PromptType::Replace {
150 search: input.clone(),
151 },
152 );
153 }
154 PromptType::Replace { search } => {
155 if self.search_confirm_each {
156 self.start_interactive_replace(&search, &input);
157 } else {
158 self.perform_replace(&search, &input);
159 }
160 }
161 PromptType::QueryReplaceSearch => {
162 self.perform_search(&input);
163 self.start_prompt(
164 t!("replace.query_prompt", search = &input).to_string(),
165 PromptType::QueryReplace {
166 search: input.clone(),
167 },
168 );
169 }
170 PromptType::QueryReplace { search } => {
171 if self.search_confirm_each {
172 self.start_interactive_replace(&search, &input);
173 } else {
174 self.perform_replace(&search, &input);
175 }
176 }
177 PromptType::Command => {
178 let commands = self.command_registry.read().unwrap().get_all();
179 if let Some(cmd) = commands.iter().find(|c| c.get_localized_name() == input) {
180 let action = cmd.action.clone();
181 let cmd_name = cmd.get_localized_name();
182 self.command_registry
183 .write()
184 .unwrap()
185 .record_usage(&cmd_name);
186 return PromptResult::ExecuteAction(action);
187 } else {
188 self.set_status_message(
189 t!("error.unknown_command", input = &input).to_string(),
190 );
191 }
192 }
193 PromptType::GotoLine => match input.trim().parse::<usize>() {
194 Ok(line_num) if line_num > 0 => {
195 self.goto_line_col(line_num, None);
196 self.set_status_message(t!("goto.jumped", line = line_num).to_string());
197 }
198 Ok(_) => {
199 self.set_status_message(t!("goto.line_must_be_positive").to_string());
200 }
201 Err(_) => {
202 self.set_status_message(t!("error.invalid_line", input = &input).to_string());
203 }
204 },
205 PromptType::GotoByteOffset => {
206 let trimmed = input.trim();
208 let num_str = trimmed
209 .strip_suffix('B')
210 .or_else(|| trimmed.strip_suffix('b'))
211 .unwrap_or(trimmed);
212 match num_str.parse::<usize>() {
213 Ok(offset) => {
214 self.goto_byte_offset(offset);
215 self.set_status_message(
216 t!("goto.jumped_byte", offset = offset).to_string(),
217 );
218 }
219 Err(_) => {
220 self.set_status_message(
221 t!("goto.invalid_byte_offset", input = &input).to_string(),
222 );
223 }
224 }
225 }
226 PromptType::GotoLineScanConfirm => {
227 let answer = input.trim().to_lowercase();
228 if answer == "y" || answer == "yes" {
229 self.start_incremental_line_scan(true);
231 } else {
234 self.start_prompt(
236 t!("goto.byte_offset_prompt").to_string(),
237 PromptType::GotoByteOffset,
238 );
239 }
240 }
241 PromptType::QuickOpen => {
242 return self.handle_quick_open_confirm(&input, selected_index);
244 }
245 PromptType::SetBackgroundFile => {
246 if let Err(e) = self.load_ansi_background(&input) {
247 self.set_status_message(
248 t!("error.background_load_failed", error = e.to_string()).to_string(),
249 );
250 }
251 }
252 PromptType::SetBackgroundBlend => match input.trim().parse::<f32>() {
253 Ok(val) => {
254 let clamped = val.clamp(0.0, 1.0);
255 self.background_fade = clamped;
256 self.set_status_message(
257 t!(
258 "error.background_blend_set",
259 value = format!("{:.2}", clamped)
260 )
261 .to_string(),
262 );
263 }
264 Err(_) => {
265 self.set_status_message(t!("error.invalid_blend", input = &input).to_string());
266 }
267 },
268 PromptType::SetPageWidth => {
269 self.handle_set_page_width(&input);
270 }
271 PromptType::RecordMacro => {
272 self.handle_register_input(
273 &input,
274 |editor, c| editor.toggle_macro_recording(c),
275 "Macro",
276 );
277 }
278 PromptType::PlayMacro => {
279 self.handle_register_input(&input, |editor, c| editor.play_macro(c), "Macro");
280 }
281 PromptType::SetBookmark => {
282 self.handle_register_input(&input, |editor, c| editor.set_bookmark(c), "Bookmark");
283 }
284 PromptType::JumpToBookmark => {
285 self.handle_register_input(
286 &input,
287 |editor, c| editor.jump_to_bookmark(c),
288 "Bookmark",
289 );
290 }
291 PromptType::Plugin { custom_type } => {
292 tracing::info!(
293 "prompt_confirmed: dispatching hook for prompt_type='{}', input='{}', selected_index={:?}",
294 custom_type, input, selected_index
295 );
296 self.plugin_manager.run_hook(
297 "prompt_confirmed",
298 HookArgs::PromptConfirmed {
299 prompt_type: custom_type.clone(),
300 input,
301 selected_index,
302 },
303 );
304 tracing::info!(
305 "prompt_confirmed: hook dispatched for prompt_type='{}'",
306 custom_type
307 );
308 }
309 PromptType::ConfirmRevert => {
310 let input_lower = input.trim().to_lowercase();
311 let revert_key = t!("prompt.key.revert").to_string().to_lowercase();
312 if input_lower == revert_key || input_lower == "revert" {
313 if let Err(e) = self.revert_file() {
314 self.set_status_message(
315 t!("file.revert_failed", error = e.to_string()).to_string(),
316 );
317 }
318 } else {
319 self.set_status_message(t!("buffer.revert_cancelled").to_string());
320 }
321 }
322 PromptType::ConfirmSaveConflict => {
323 let input_lower = input.trim().to_lowercase();
324 if input_lower == "o" || input_lower == "overwrite" {
325 if let Err(e) = self.save() {
326 self.set_status_message(
327 t!("file.save_failed", error = e.to_string()).to_string(),
328 );
329 }
330 } else {
331 self.set_status_message(t!("buffer.save_cancelled").to_string());
332 }
333 }
334 PromptType::ConfirmSudoSave { info } => {
335 let input_lower = input.trim().to_lowercase();
336 if input_lower == "y" || input_lower == "yes" {
337 self.cancel_prompt();
339
340 let result = (|| -> anyhow::Result<()> {
342 let data = self.filesystem.read_file(&info.temp_path)?;
343 self.filesystem.sudo_write(
344 &info.dest_path,
345 &data,
346 info.mode,
347 info.uid,
348 info.gid,
349 )?;
350 #[allow(clippy::let_underscore_must_use)]
352 let _ = self.filesystem.remove_file(&info.temp_path);
353 Ok(())
354 })();
355
356 match result {
357 Ok(_) => {
358 if let Err(e) = self
359 .active_state_mut()
360 .buffer
361 .finalize_external_save(info.dest_path.clone())
362 {
363 tracing::warn!("Failed to finalize sudo save: {}", e);
364 self.set_status_message(
365 t!("prompt.sudo_save_failed", error = e.to_string())
366 .to_string(),
367 );
368 } else if let Err(e) = self.finalize_save(Some(info.dest_path)) {
369 tracing::warn!("Failed to finalize save after sudo: {}", e);
370 self.set_status_message(
371 t!("prompt.sudo_save_failed", error = e.to_string())
372 .to_string(),
373 );
374 }
375 }
376 Err(e) => {
377 tracing::warn!("Sudo save failed: {}", e);
378 self.set_status_message(
379 t!("prompt.sudo_save_failed", error = e.to_string()).to_string(),
380 );
381 #[allow(clippy::let_underscore_must_use)]
383 let _ = self.filesystem.remove_file(&info.temp_path);
384 }
385 }
386 } else {
387 self.set_status_message(t!("buffer.save_cancelled").to_string());
388 #[allow(clippy::let_underscore_must_use)]
390 let _ = self.filesystem.remove_file(&info.temp_path);
391 }
392 }
393 PromptType::ConfirmOverwriteFile { path } => {
394 let input_lower = input.trim().to_lowercase();
395 if input_lower == "o" || input_lower == "overwrite" {
396 self.perform_save_file_as(path);
397 } else {
398 self.set_status_message(t!("buffer.save_cancelled").to_string());
399 }
400 }
401 PromptType::ConfirmCloseBuffer { buffer_id } => {
402 if self.handle_confirm_close_buffer(&input, buffer_id) {
403 return PromptResult::EarlyReturn;
404 }
405 }
406 PromptType::ConfirmQuitWithModified => {
407 if self.handle_confirm_quit_modified(&input) {
408 return PromptResult::EarlyReturn;
409 }
410 }
411 PromptType::LspRename {
412 original_text,
413 start_pos,
414 end_pos: _,
415 overlay_handle,
416 } => {
417 self.perform_lsp_rename(input, original_text, start_pos, overlay_handle);
418 }
419 PromptType::FileExplorerRename {
420 original_path,
421 original_name,
422 is_new_file,
423 } => {
424 self.perform_file_explorer_rename(original_path, original_name, input, is_new_file);
425 }
426 PromptType::ConfirmDeleteFile { path, is_dir } => {
427 let input_lower = input.trim().to_lowercase();
428 if input_lower == "y" || input_lower == "yes" {
429 self.perform_file_explorer_delete(path, is_dir);
430 } else {
431 self.set_status_message(t!("explorer.delete_cancelled").to_string());
432 }
433 }
434 PromptType::ConfirmLargeFileEncoding { path } => {
435 let input_lower = input.trim().to_lowercase();
436 let load_key = t!("file.large_encoding.key.load")
437 .to_string()
438 .to_lowercase();
439 let encoding_key = t!("file.large_encoding.key.encoding")
440 .to_string()
441 .to_lowercase();
442 let cancel_key = t!("file.large_encoding.key.cancel")
443 .to_string()
444 .to_lowercase();
445 if input_lower.is_empty() || input_lower == load_key {
447 if let Err(e) = self.open_file_large_encoding_confirmed(&path) {
448 self.set_status_message(
449 t!("file.error_opening", error = e.to_string()).to_string(),
450 );
451 }
452 } else if input_lower == encoding_key {
453 self.start_open_file_with_encoding_prompt(path);
455 } else if input_lower == cancel_key {
456 self.set_status_message(t!("file.open_cancelled").to_string());
457 } else {
458 if let Err(e) = self.open_file_large_encoding_confirmed(&path) {
460 self.set_status_message(
461 t!("file.error_opening", error = e.to_string()).to_string(),
462 );
463 }
464 }
465 }
466 PromptType::StopLspServer => {
467 self.handle_stop_lsp_server(&input);
468 }
469 PromptType::RestartLspServer => {
470 self.handle_restart_lsp_server(&input);
471 }
472 PromptType::SelectTheme { .. } => {
473 self.apply_theme(input.trim());
474 }
475 PromptType::SelectKeybindingMap => {
476 self.apply_keybinding_map(input.trim());
477 }
478 PromptType::SelectCursorStyle => {
479 self.apply_cursor_style(input.trim());
480 }
481 PromptType::SelectLocale => {
482 self.apply_locale(input.trim());
483 }
484 PromptType::CopyWithFormattingTheme => {
485 self.copy_selection_with_theme(input.trim());
486 }
487 PromptType::SwitchToTab => {
488 if let Ok(id) = input.trim().parse::<usize>() {
489 self.switch_to_tab(BufferId(id));
490 }
491 }
492 PromptType::QueryReplaceConfirm => {
493 if let Some(c) = input.chars().next() {
496 if let Err(e) = self.handle_interactive_replace_key(c) {
497 tracing::warn!("Interactive replace failed: {}", e);
498 }
499 }
500 }
501 PromptType::AddRuler => {
502 self.handle_add_ruler(&input);
503 }
504 PromptType::RemoveRuler => {
505 self.handle_remove_ruler(&input);
506 }
507 PromptType::SetTabSize => {
508 self.handle_set_tab_size(&input);
509 }
510 PromptType::SetLineEnding => {
511 self.handle_set_line_ending(&input);
512 }
513 PromptType::SetEncoding => {
514 self.handle_set_encoding(&input);
515 }
516 PromptType::SetLanguage => {
517 self.handle_set_language(&input);
518 }
519 PromptType::ShellCommand { replace } => {
520 self.handle_shell_command(&input, replace);
521 }
522 PromptType::AsyncPrompt => {
523 if let Some(callback_id) = self.pending_async_prompt_callback.take() {
525 let json = serde_json::to_string(&input).unwrap_or_else(|_| "null".to_string());
527 self.plugin_manager.resolve_callback(callback_id, json);
528 }
529 }
530 }
531 PromptResult::Done
532 }
533
534 fn handle_save_file_as(&mut self, input: &str) {
536 let expanded_path = expand_tilde(input);
538 let full_path = if expanded_path.is_absolute() {
539 normalize_path(&expanded_path)
540 } else {
541 normalize_path(&self.working_dir.join(&expanded_path))
542 };
543
544 let current_file_path = self
546 .active_state()
547 .buffer
548 .file_path()
549 .map(|p| p.to_path_buf());
550 let is_different_file = current_file_path.as_ref() != Some(&full_path);
551
552 if is_different_file && full_path.is_file() {
553 let filename = full_path
555 .file_name()
556 .map(|n| n.to_string_lossy().to_string())
557 .unwrap_or_else(|| full_path.display().to_string());
558 self.start_prompt(
559 t!("buffer.overwrite_confirm", name = &filename).to_string(),
560 PromptType::ConfirmOverwriteFile { path: full_path },
561 );
562 return;
563 }
564
565 self.perform_save_file_as(full_path);
567 }
568
569 pub(crate) fn perform_save_file_as(&mut self, full_path: std::path::PathBuf) {
571 let before_idx = self.active_event_log().current_index();
572 let before_len = self.active_event_log().len();
573 tracing::debug!(
574 "SaveFileAs BEFORE: event_log index={}, len={}",
575 before_idx,
576 before_len
577 );
578
579 match self.active_state_mut().buffer.save_to_file(&full_path) {
580 Ok(()) => {
581 let after_save_idx = self.active_event_log().current_index();
582 let after_save_len = self.active_event_log().len();
583 tracing::debug!(
584 "SaveFileAs AFTER buffer.save_to_file: event_log index={}, len={}",
585 after_save_idx,
586 after_save_len
587 );
588
589 let metadata = BufferMetadata::with_file(full_path.clone(), &self.working_dir);
590 self.buffer_metadata.insert(self.active_buffer(), metadata);
591
592 let mut language_changed = false;
595 let mut new_language = String::new();
596 if let Some(state) = self.buffers.get_mut(&self.active_buffer()) {
597 if state.language == "text" {
598 let detected =
599 crate::primitives::detected_language::DetectedLanguage::from_path(
600 &full_path,
601 &self.grammar_registry,
602 &self.config.languages,
603 );
604 new_language = detected.name.clone();
605 state.apply_language(detected);
606 language_changed = new_language != "text";
607 }
608 }
609 if language_changed {
610 #[cfg(feature = "plugins")]
611 self.update_plugin_state_snapshot();
612 self.plugin_manager.run_hook(
613 "language_changed",
614 crate::services::plugins::hooks::HookArgs::LanguageChanged {
615 buffer_id: self.active_buffer(),
616 language: new_language,
617 },
618 );
619 }
620
621 self.active_event_log_mut().mark_saved();
622 tracing::debug!(
623 "SaveFileAs AFTER mark_saved: event_log index={}, len={}",
624 self.active_event_log().current_index(),
625 self.active_event_log().len()
626 );
627
628 if let Ok(metadata) = self.filesystem.metadata(&full_path) {
629 if let Some(mtime) = metadata.modified {
630 self.file_mod_times.insert(full_path.clone(), mtime);
631 }
632 }
633
634 self.notify_lsp_save();
635
636 self.emit_event(
637 crate::model::control_event::events::FILE_SAVED.name,
638 serde_json::json!({"path": full_path.display().to_string()}),
639 );
640
641 self.plugin_manager.run_hook(
642 "after_file_save",
643 crate::services::plugins::hooks::HookArgs::AfterFileSave {
644 buffer_id: self.active_buffer(),
645 path: full_path.clone(),
646 },
647 );
648
649 if let Some(buffer_to_close) = self.pending_close_buffer.take() {
650 if let Err(e) = self.force_close_buffer(buffer_to_close) {
651 self.set_status_message(
652 t!("file.saved_cannot_close", error = e.to_string()).to_string(),
653 );
654 } else {
655 self.set_status_message(t!("buffer.saved_and_closed").to_string());
656 }
657 } else {
658 self.set_status_message(
659 t!("file.saved_as", path = full_path.display().to_string()).to_string(),
660 );
661 }
662 }
663 Err(e) => {
664 self.pending_close_buffer = None;
665 self.set_status_message(t!("file.error_saving", error = e.to_string()).to_string());
666 }
667 }
668 }
669
670 fn handle_set_page_width(&mut self, input: &str) {
672 let active_split = self.split_manager.active_split();
673 let trimmed = input.trim();
674
675 if trimmed.is_empty() {
676 if let Some(vs) = self.split_view_states.get_mut(&active_split) {
677 vs.compose_width = None;
678 }
679 self.set_status_message(t!("settings.page_width_cleared").to_string());
680 } else {
681 match trimmed.parse::<u16>() {
682 Ok(val) if val > 0 => {
683 if let Some(vs) = self.split_view_states.get_mut(&active_split) {
684 vs.compose_width = Some(val);
685 }
686 self.set_status_message(t!("settings.page_width_set", value = val).to_string());
687 }
688 _ => {
689 self.set_status_message(
690 t!("error.invalid_page_width", input = input).to_string(),
691 );
692 }
693 }
694 }
695 }
696
697 fn handle_add_ruler(&mut self, input: &str) {
699 let trimmed = input.trim();
700 match trimmed.parse::<usize>() {
701 Ok(col) if col > 0 => {
702 let active_split = self.split_manager.active_split();
703 if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
704 if !view_state.rulers.contains(&col) {
705 view_state.rulers.push(col);
706 view_state.rulers.sort();
707 }
708 }
709 self.config.editor.rulers = self
711 .split_view_states
712 .get(&active_split)
713 .map(|vs| vs.rulers.clone())
714 .unwrap_or_default();
715 self.save_rulers_to_config();
716 self.set_status_message(t!("rulers.added", column = col).to_string());
717 }
718 Ok(_) => {
719 self.set_status_message(t!("rulers.must_be_positive").to_string());
720 }
721 Err(_) => {
722 self.set_status_message(t!("rulers.invalid_column", input = input).to_string());
723 }
724 }
725 }
726
727 fn handle_remove_ruler(&mut self, input: &str) {
729 let trimmed = input.trim();
730 if let Ok(col) = trimmed.parse::<usize>() {
731 let active_split = self.split_manager.active_split();
732 if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
733 view_state.rulers.retain(|&r| r != col);
734 }
735 self.config.editor.rulers = self
737 .split_view_states
738 .get(&active_split)
739 .map(|vs| vs.rulers.clone())
740 .unwrap_or_default();
741 self.save_rulers_to_config();
742 self.set_status_message(t!("rulers.removed", column = col).to_string());
743 }
744 }
745
746 fn save_rulers_to_config(&mut self) {
748 if let Err(e) = self.filesystem.create_dir_all(&self.dir_context.config_dir) {
749 tracing::warn!("Failed to create config directory: {}", e);
750 return;
751 }
752 let resolver = ConfigResolver::new(self.dir_context.clone(), self.working_dir.clone());
753 if let Err(e) = resolver.save_to_layer(&self.config, ConfigLayer::User) {
754 tracing::warn!("Failed to save rulers to config: {}", e);
755 }
756 }
757
758 fn handle_set_tab_size(&mut self, input: &str) {
760 let buffer_id = self.active_buffer();
761 let trimmed = input.trim();
762
763 match trimmed.parse::<usize>() {
764 Ok(val) if val > 0 => {
765 if let Some(state) = self.buffers.get_mut(&buffer_id) {
766 state.buffer_settings.tab_size = val;
767 }
768 self.set_status_message(t!("settings.tab_size_set", value = val).to_string());
769 }
770 Ok(_) => {
771 self.set_status_message(t!("settings.tab_size_positive").to_string());
772 }
773 Err(_) => {
774 self.set_status_message(t!("error.invalid_tab_size", input = input).to_string());
775 }
776 }
777 }
778
779 fn handle_set_line_ending(&mut self, input: &str) {
781 use crate::model::buffer::LineEnding;
782
783 let trimmed = input.trim();
785 let code = trimmed.split_whitespace().next().unwrap_or(trimmed);
786
787 let line_ending = match code.to_uppercase().as_str() {
788 "LF" => Some(LineEnding::LF),
789 "CRLF" => Some(LineEnding::CRLF),
790 "CR" => Some(LineEnding::CR),
791 _ => None,
792 };
793
794 match line_ending {
795 Some(le) => {
796 self.active_state_mut().buffer.set_line_ending(le);
797 self.set_status_message(
798 t!("settings.line_ending_set", value = le.display_name()).to_string(),
799 );
800 }
801 None => {
802 self.set_status_message(t!("error.unknown_line_ending", input = input).to_string());
803 }
804 }
805 }
806
807 fn handle_set_encoding(&mut self, input: &str) {
809 use crate::model::buffer::Encoding;
810
811 let trimmed = input.trim();
812
813 let encoding = Encoding::all()
816 .iter()
817 .find(|enc| enc.display_name().eq_ignore_ascii_case(trimmed))
818 .copied()
819 .or_else(|| {
820 let before_paren = trimmed.split('(').next().unwrap_or(trimmed).trim();
822 Encoding::all()
823 .iter()
824 .find(|enc| enc.display_name().eq_ignore_ascii_case(before_paren))
825 .copied()
826 });
827
828 match encoding {
829 Some(enc) => {
830 self.active_state_mut().buffer.set_encoding(enc);
831 self.set_status_message(format!("Encoding set to {}", enc.display_name()));
832 }
833 None => {
834 self.set_status_message(format!("Unknown encoding: {}", input));
835 }
836 }
837 }
838
839 fn handle_open_file_with_encoding(&mut self, path: &std::path::Path, input: &str) {
845 use crate::model::buffer::Encoding;
846 use crate::view::prompt::PromptType;
847
848 let trimmed = input.trim();
849
850 let encoding = Encoding::all()
852 .iter()
853 .find(|enc| enc.display_name().eq_ignore_ascii_case(trimmed))
854 .copied()
855 .or_else(|| {
856 let before_paren = trimmed.split('(').next().unwrap_or(trimmed).trim();
857 Encoding::all()
858 .iter()
859 .find(|enc| enc.display_name().eq_ignore_ascii_case(before_paren))
860 .copied()
861 });
862
863 match encoding {
864 Some(enc) => {
865 let threshold = self.config.editor.large_file_threshold_bytes as usize;
868 let file_size = self
869 .filesystem
870 .metadata(path)
871 .map(|m| m.size as usize)
872 .unwrap_or(0);
873
874 if file_size >= threshold && enc.requires_full_file_load() {
875 let size_mb = file_size as f64 / (1024.0 * 1024.0);
877 let load_key = t!("file.large_encoding.key.load").to_string();
878 let encoding_key = t!("file.large_encoding.key.encoding").to_string();
879 let cancel_key = t!("file.large_encoding.key.cancel").to_string();
880 let prompt_msg = t!(
881 "file.large_encoding_prompt",
882 encoding = enc.display_name(),
883 size = format!("{:.0}", size_mb),
884 load_key = load_key,
885 encoding_key = encoding_key,
886 cancel_key = cancel_key
887 )
888 .to_string();
889 self.start_prompt(
890 prompt_msg,
891 PromptType::ConfirmLargeFileEncoding {
892 path: path.to_path_buf(),
893 },
894 );
895 return;
896 }
897
898 self.key_context = crate::input::keybindings::KeyContext::Normal;
900
901 if let Err(e) = self.open_file_with_encoding(path, enc) {
903 self.set_status_message(
904 t!("file.error_opening", error = e.to_string()).to_string(),
905 );
906 } else {
907 self.set_status_message(format!(
908 "Opened {} with {} encoding",
909 path.display(),
910 enc.display_name()
911 ));
912 }
913 }
914 None => {
915 self.set_status_message(format!("Unknown encoding: {}", input));
916 }
917 }
918 }
919
920 fn handle_reload_with_encoding(&mut self, input: &str) {
923 use crate::model::buffer::Encoding;
924
925 let trimmed = input.trim();
926
927 let encoding = Encoding::all()
929 .iter()
930 .find(|enc| enc.display_name().eq_ignore_ascii_case(trimmed))
931 .copied()
932 .or_else(|| {
933 let before_paren = trimmed.split('(').next().unwrap_or(trimmed).trim();
934 Encoding::all()
935 .iter()
936 .find(|enc| enc.display_name().eq_ignore_ascii_case(before_paren))
937 .copied()
938 });
939
940 match encoding {
941 Some(enc) => {
942 if let Err(e) = self.reload_with_encoding(enc) {
944 self.set_status_message(format!("Failed to reload: {}", e));
945 } else {
946 self.set_status_message(format!(
947 "Reloaded with {} encoding",
948 enc.display_name()
949 ));
950 }
951 }
952 None => {
953 self.set_status_message(format!("Unknown encoding: {}", input));
954 }
955 }
956 }
957
958 fn handle_set_language(&mut self, input: &str) {
960 use crate::primitives::detected_language::DetectedLanguage;
961
962 let trimmed = input.trim();
963
964 if trimmed == "Plain Text" || trimmed.to_lowercase() == "text" {
966 let buffer_id = self.active_buffer();
967 if let Some(state) = self.buffers.get_mut(&buffer_id) {
968 state.apply_language(DetectedLanguage::plain_text());
969 self.set_status_message("Language set to Plain Text".to_string());
970 }
971 #[cfg(feature = "plugins")]
972 self.update_plugin_state_snapshot();
973 self.plugin_manager.run_hook(
974 "language_changed",
975 crate::services::plugins::hooks::HookArgs::LanguageChanged {
976 buffer_id: self.active_buffer(),
977 language: "text".to_string(),
978 },
979 );
980 return;
981 }
982
983 if let Some(detected) = DetectedLanguage::from_syntax_name(
985 trimmed,
986 &self.grammar_registry,
987 &self.config.languages,
988 ) {
989 let language = detected.name.clone();
990 let buffer_id = self.active_buffer();
991 if let Some(state) = self.buffers.get_mut(&buffer_id) {
992 state.apply_language(detected);
993 self.set_status_message(format!("Language set to {}", trimmed));
994 }
995 #[cfg(feature = "plugins")]
996 self.update_plugin_state_snapshot();
997 self.plugin_manager.run_hook(
998 "language_changed",
999 crate::services::plugins::hooks::HookArgs::LanguageChanged {
1000 buffer_id,
1001 language,
1002 },
1003 );
1004 } else if self.config.languages.contains_key(trimmed) {
1005 let detected = DetectedLanguage::from_config_language(trimmed);
1010 let language = detected.name.clone();
1011 let buffer_id = self.active_buffer();
1012 if let Some(state) = self.buffers.get_mut(&buffer_id) {
1013 state.apply_language(detected);
1014 self.set_status_message(format!("Language set to {}", trimmed));
1015 }
1016 #[cfg(feature = "plugins")]
1017 self.update_plugin_state_snapshot();
1018 self.plugin_manager.run_hook(
1019 "language_changed",
1020 crate::services::plugins::hooks::HookArgs::LanguageChanged {
1021 buffer_id,
1022 language,
1023 },
1024 );
1025 } else {
1026 self.set_status_message(format!("Unknown language: {}", input));
1027 }
1028 }
1029
1030 fn handle_register_input<F>(&mut self, input: &str, action: F, register_type: &str)
1032 where
1033 F: FnOnce(&mut Self, char),
1034 {
1035 if let Some(c) = input.trim().chars().next() {
1036 if c.is_ascii_digit() {
1037 action(self, c);
1038 } else {
1039 self.set_status_message(
1040 t!("register.must_be_digit", "type" = register_type).to_string(),
1041 );
1042 }
1043 } else {
1044 self.set_status_message(t!("register.not_specified").to_string());
1045 }
1046 }
1047
1048 fn handle_confirm_close_buffer(&mut self, input: &str, buffer_id: BufferId) -> bool {
1050 let input_lower = input.trim().to_lowercase();
1051 let save_key = t!("prompt.key.save").to_string().to_lowercase();
1052 let discard_key = t!("prompt.key.discard").to_string().to_lowercase();
1053
1054 let first_char = input_lower.chars().next();
1055 let save_first = save_key.chars().next();
1056 let discard_first = discard_key.chars().next();
1057
1058 if first_char == save_first {
1059 let has_path = self
1061 .buffers
1062 .get(&buffer_id)
1063 .map(|s| s.buffer.file_path().is_some())
1064 .unwrap_or(false);
1065
1066 if has_path {
1067 let old_active = self.active_buffer();
1068 self.set_active_buffer(buffer_id);
1069 if let Err(e) = self.save() {
1070 self.set_status_message(
1071 t!("file.save_failed", error = e.to_string()).to_string(),
1072 );
1073 self.set_active_buffer(old_active);
1074 return true; }
1076 self.set_active_buffer(old_active);
1077 if let Err(e) = self.force_close_buffer(buffer_id) {
1078 self.set_status_message(
1079 t!("file.cannot_close", error = e.to_string()).to_string(),
1080 );
1081 } else {
1082 self.set_status_message(t!("buffer.saved_and_closed").to_string());
1083 }
1084 } else {
1085 self.pending_close_buffer = Some(buffer_id);
1086 self.start_prompt_with_initial_text(
1087 t!("file.save_as_prompt").to_string(),
1088 PromptType::SaveFileAs,
1089 String::new(),
1090 );
1091 }
1092 } else if first_char == discard_first {
1093 if let Err(e) = self.force_close_buffer(buffer_id) {
1095 self.set_status_message(t!("file.cannot_close", error = e.to_string()).to_string());
1096 } else {
1097 self.set_status_message(t!("buffer.changes_discarded").to_string());
1098 }
1099 } else {
1100 self.set_status_message(t!("buffer.close_cancelled").to_string());
1101 }
1102 false
1103 }
1104
1105 fn handle_confirm_quit_modified(&mut self, input: &str) -> bool {
1107 let input_lower = input.trim().to_lowercase();
1108 let save_key = t!("prompt.key.save").to_string().to_lowercase();
1109 let discard_key = t!("prompt.key.discard").to_string().to_lowercase();
1110 let quit_key = t!("prompt.key.quit").to_string().to_lowercase();
1111
1112 let first_char = input_lower.chars().next();
1113 let save_first = save_key.chars().next();
1114 let discard_first = discard_key.chars().next();
1115 let quit_first = quit_key.chars().next();
1116
1117 if first_char == save_first {
1118 match self.save_all_on_exit() {
1120 Ok(count) => {
1121 tracing::info!("Saved {} buffer(s) on exit", count);
1122 self.should_quit = true;
1123 }
1124 Err(e) => {
1125 self.set_status_message(
1126 t!("file.save_failed", error = e.to_string()).to_string(),
1127 );
1128 return true; }
1130 }
1131 } else if first_char == discard_first {
1132 self.should_quit = true;
1134 } else if first_char == quit_first && self.config.editor.hot_exit {
1135 self.should_quit = true;
1137 } else {
1138 self.set_status_message(t!("buffer.close_cancelled").to_string());
1140 }
1141 false
1142 }
1143
1144 pub fn handle_stop_lsp_server(&mut self, input: &str) {
1149 let input = input.trim();
1150 if input.is_empty() {
1151 return;
1152 }
1153
1154 let (language, server_name) = if let Some((lang, name)) = input.split_once('/') {
1156 (lang, Some(name))
1157 } else {
1158 (input, None)
1159 };
1160
1161 let has_server = self
1162 .lsp
1163 .as_ref()
1164 .is_some_and(|lsp| !lsp.get_handles(language).is_empty());
1165
1166 if !has_server {
1167 self.set_status_message(t!("lsp.server_not_found", language = language).to_string());
1168 return;
1169 }
1170
1171 let stopping_all = server_name.is_none()
1175 || self
1176 .lsp
1177 .as_ref()
1178 .map(|lsp| lsp.get_handles(language).len() <= 1)
1179 .unwrap_or(true);
1180
1181 if stopping_all {
1182 let buffer_ids: Vec<_> = self
1186 .buffers
1187 .iter()
1188 .filter(|(_, s)| s.language == language)
1189 .map(|(id, _)| *id)
1190 .collect();
1191 for buffer_id in buffer_ids {
1192 self.disable_lsp_for_buffer(buffer_id);
1193 }
1194 } else if let Some(name) = server_name {
1195 self.send_did_close_to_server(language, name);
1197 self.clear_diagnostics_for_server(name);
1199 }
1200
1201 let stopped = if let Some(lsp) = &mut self.lsp {
1203 if let Some(name) = server_name {
1204 lsp.shutdown_server_by_name(language, name)
1205 } else {
1206 lsp.shutdown_server(language)
1207 }
1208 } else {
1209 false
1210 };
1211
1212 if !stopped {
1213 self.set_status_message(t!("lsp.server_not_found", language = language).to_string());
1214 return;
1215 }
1216
1217 if let Some(lsp_configs) = self.config.lsp.get_mut(language) {
1219 for c in lsp_configs.as_mut_slice() {
1220 if let Some(name) = server_name {
1221 if c.display_name() == name {
1223 c.auto_start = false;
1224 }
1225 } else {
1226 c.auto_start = false;
1227 }
1228 }
1229 if let Err(e) = self.save_config() {
1230 tracing::warn!(
1231 "Failed to save config after disabling LSP auto-start: {}",
1232 e
1233 );
1234 } else {
1235 let config_path = self.dir_context.config_path();
1236 self.emit_event(
1237 "config_changed",
1238 serde_json::json!({
1239 "path": config_path.to_string_lossy(),
1240 }),
1241 );
1242 }
1243 }
1244
1245 let display = server_name.unwrap_or(language);
1246 self.set_status_message(t!("lsp.server_stopped", language = display).to_string());
1247 }
1248
1249 pub fn handle_restart_lsp_server(&mut self, input: &str) {
1254 let input = input.trim();
1255 if input.is_empty() {
1256 return;
1257 }
1258
1259 let (language, server_name) = if let Some((lang, name)) = input.split_once('/') {
1261 (lang, Some(name))
1262 } else {
1263 (input, None)
1264 };
1265
1266 let buffer_id = self.active_buffer();
1268 let file_path = self
1269 .buffer_metadata
1270 .get(&buffer_id)
1271 .and_then(|meta| meta.file_path().cloned());
1272
1273 let (success, message) = if let Some(name) = server_name {
1274 if let Some(lsp) = self.lsp.as_mut() {
1276 lsp.manual_restart_server(language, name, file_path.as_deref())
1277 } else {
1278 (false, t!("lsp.no_manager").to_string())
1279 }
1280 } else {
1281 if let Some(lsp) = self.lsp.as_mut() {
1283 lsp.manual_restart(language, file_path.as_deref())
1284 } else {
1285 (false, t!("lsp.no_manager").to_string())
1286 }
1287 };
1288
1289 self.status_message = Some(message);
1290
1291 if success {
1292 self.reopen_buffers_for_language(language);
1293 }
1294 }
1295
1296 fn handle_quick_open_confirm(
1298 &mut self,
1299 input: &str,
1300 selected_index: Option<usize>,
1301 ) -> PromptResult {
1302 if let Some(query) = input.strip_prefix('>') {
1304 return self.handle_quick_open_command(query, selected_index);
1306 }
1307
1308 if let Some(query) = input.strip_prefix('#') {
1309 return self.handle_quick_open_buffer(query, selected_index);
1311 }
1312
1313 if let Some(line_str) = input.strip_prefix(':') {
1314 if let Ok(line_num) = line_str.parse::<usize>() {
1316 if line_num > 0 {
1317 self.goto_line_col(line_num, None);
1318 self.set_status_message(t!("goto.jumped", line = line_num).to_string());
1319 } else {
1320 self.set_status_message(t!("goto.line_must_be_positive").to_string());
1321 }
1322 } else {
1323 self.set_status_message(t!("error.invalid_line", input = line_str).to_string());
1324 }
1325 return PromptResult::Done;
1326 }
1327
1328 self.handle_quick_open_file(input, selected_index)
1330 }
1331
1332 fn handle_quick_open_command(
1334 &mut self,
1335 query: &str,
1336 selected_index: Option<usize>,
1337 ) -> PromptResult {
1338 let suggestions = {
1339 let registry = self.command_registry.read().unwrap();
1340 let selection_active = self.has_active_selection();
1341 let active_buffer_mode = self
1342 .buffer_metadata
1343 .get(&self.active_buffer())
1344 .and_then(|m| m.virtual_mode());
1345 let has_lsp_config = {
1346 let language = self
1347 .buffers
1348 .get(&self.active_buffer())
1349 .map(|s| s.language.as_str());
1350 language
1351 .and_then(|lang| self.lsp.as_ref().and_then(|lsp| lsp.get_config(lang)))
1352 .is_some()
1353 };
1354
1355 registry.filter(
1356 query,
1357 self.key_context.clone(),
1358 &self.keybindings,
1359 selection_active,
1360 &self.active_custom_contexts,
1361 active_buffer_mode,
1362 has_lsp_config,
1363 )
1364 };
1365
1366 if let Some(idx) = selected_index {
1367 if let Some(suggestion) = suggestions.get(idx) {
1368 if suggestion.disabled {
1369 self.set_status_message(t!("status.command_not_available").to_string());
1370 return PromptResult::Done;
1371 }
1372
1373 let commands = self.command_registry.read().unwrap().get_all();
1375 if let Some(cmd) = commands
1376 .iter()
1377 .find(|c| c.get_localized_name() == suggestion.text)
1378 {
1379 let action = cmd.action.clone();
1380 let cmd_name = cmd.get_localized_name();
1381 self.command_registry
1382 .write()
1383 .unwrap()
1384 .record_usage(&cmd_name);
1385 return PromptResult::ExecuteAction(action);
1386 }
1387 }
1388 }
1389
1390 self.set_status_message(t!("status.no_selection").to_string());
1391 PromptResult::Done
1392 }
1393
1394 fn handle_quick_open_buffer(
1396 &mut self,
1397 query: &str,
1398 selected_index: Option<usize>,
1399 ) -> PromptResult {
1400 let suggestions = self.get_buffer_suggestions(query);
1402
1403 if let Some(idx) = selected_index {
1404 if let Some(suggestion) = suggestions.get(idx) {
1405 if let Some(value) = &suggestion.value {
1406 if let Ok(buffer_id) = value.parse::<usize>() {
1407 let buffer_id = crate::model::event::BufferId(buffer_id);
1408 if self.buffers.contains_key(&buffer_id) {
1409 self.set_active_buffer(buffer_id);
1410 if let Some(name) = self.active_state().buffer.file_path() {
1411 self.set_status_message(
1412 t!("buffer.switched", name = name.display().to_string())
1413 .to_string(),
1414 );
1415 }
1416 return PromptResult::Done;
1417 }
1418 }
1419 }
1420 }
1421 }
1422
1423 self.set_status_message(t!("status.no_selection").to_string());
1424 PromptResult::Done
1425 }
1426
1427 fn open_file_with_jump(
1428 &mut self,
1429 full_path: std::path::PathBuf,
1430 line: Option<usize>,
1431 column: Option<usize>,
1432 ) {
1433 match self.open_file(&full_path) {
1434 Ok(_) => {
1435 if let Some(line) = line {
1436 self.goto_line_col(line, column);
1437 }
1438 self.set_status_message(
1439 t!("buffer.opened", name = full_path.display().to_string()).to_string(),
1440 );
1441 }
1442 Err(e) => {
1443 if let Some(confirmation) =
1445 e.downcast_ref::<crate::model::buffer::LargeFileEncodingConfirmation>()
1446 {
1447 self.start_large_file_encoding_confirmation(confirmation);
1448 } else {
1449 self.set_status_message(
1450 t!("file.error_opening", error = e.to_string()).to_string(),
1451 );
1452 }
1453 }
1454 }
1455 }
1456
1457 fn handle_quick_open_file(
1459 &mut self,
1460 input: &str,
1461 selected_index: Option<usize>,
1462 ) -> PromptResult {
1463 let (path_from_input, line, column) = parse_path_line_col(input);
1464 let suggestion_input = if path_from_input.is_empty() {
1467 input
1468 } else {
1469 &path_from_input
1470 };
1471 let suggestions = self.get_file_suggestions(suggestion_input);
1472
1473 if let Some(idx) = selected_index {
1474 if let Some(suggestion) = suggestions.get(idx) {
1475 if let Some(path_str) = &suggestion.value {
1476 let path = std::path::PathBuf::from(path_str);
1477 let full_path = if path.is_absolute() {
1478 path
1479 } else {
1480 self.working_dir.join(&path)
1481 };
1482
1483 self.file_provider.record_access(path_str);
1485
1486 self.open_file_with_jump(full_path, line, column);
1487 return PromptResult::Done;
1488 }
1489 }
1490 }
1491
1492 if line.is_some() && !path_from_input.is_empty() {
1493 let expanded_path = expand_tilde(&path_from_input);
1494 let full_path = if expanded_path.is_absolute() {
1495 expanded_path
1496 } else {
1497 self.working_dir.join(&expanded_path)
1498 };
1499
1500 self.file_provider.record_access(&path_from_input);
1502
1503 self.open_file_with_jump(full_path, line, column);
1504 return PromptResult::Done;
1505 }
1506
1507 self.set_status_message(t!("status.no_selection").to_string());
1508 PromptResult::Done
1509 }
1510}
1511
1512#[cfg(test)]
1517mod tests {
1518 use super::parse_path_line_col;
1519
1520 #[test]
1521 fn test_parse_path_line_col_empty() {
1522 let (path, line, col) = parse_path_line_col("");
1523 assert_eq!(path, "");
1524 assert_eq!(line, None);
1525 assert_eq!(col, None);
1526 }
1527
1528 #[test]
1529 fn test_parse_path_line_col_plain_path() {
1530 let (path, line, col) = parse_path_line_col("src/main.rs");
1531 assert_eq!(path, "src/main.rs");
1532 assert_eq!(line, None);
1533 assert_eq!(col, None);
1534 }
1535
1536 #[test]
1537 fn test_parse_path_line_col_line_only() {
1538 let (path, line, col) = parse_path_line_col("src/main.rs:42");
1539 assert_eq!(path, "src/main.rs");
1540 assert_eq!(line, Some(42));
1541 assert_eq!(col, None);
1542 }
1543
1544 #[test]
1545 fn test_parse_path_line_col_line_and_col() {
1546 let (path, line, col) = parse_path_line_col("src/main.rs:42:10");
1547 assert_eq!(path, "src/main.rs");
1548 assert_eq!(line, Some(42));
1549 assert_eq!(col, Some(10));
1550 }
1551
1552 #[test]
1553 fn test_parse_path_line_col_trimmed() {
1554 let (path, line, col) = parse_path_line_col(" src/main.rs:5:2 ");
1555 assert_eq!(path, "src/main.rs");
1556 assert_eq!(line, Some(5));
1557 assert_eq!(col, Some(2));
1558 }
1559
1560 #[test]
1561 fn test_parse_path_line_col_zero_line_rejected() {
1562 let (path, line, col) = parse_path_line_col("src/main.rs:0");
1563 assert_eq!(path, "src/main.rs:0");
1564 assert_eq!(line, None);
1565 assert_eq!(col, None);
1566 }
1567
1568 #[test]
1569 fn test_parse_path_line_col_zero_col_rejected() {
1570 let (path, line, col) = parse_path_line_col("src/main.rs:1:0");
1571 assert_eq!(path, "src/main.rs:1:0");
1572 assert_eq!(line, None);
1573 assert_eq!(col, None);
1574 }
1575
1576 #[cfg(windows)]
1577 #[test]
1578 fn test_parse_path_line_col_windows_drive() {
1579 let (path, line, col) = parse_path_line_col(r"C:\src\main.rs:12:3");
1580 assert_eq!(path, r"C:\src\main.rs");
1581 assert_eq!(line, Some(12));
1582 assert_eq!(col, Some(3));
1583 }
1584}