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::SetComposeWidth => {
269 self.handle_set_compose_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 let input_lower = input.trim().to_lowercase();
408 let discard_key = t!("prompt.key.discard").to_string().to_lowercase();
409 if input_lower == discard_key || input_lower == "discard" {
410 self.should_quit = true;
411 } else {
412 self.set_status_message(t!("buffer.close_cancelled").to_string());
413 }
414 }
415 PromptType::LspRename {
416 original_text,
417 start_pos,
418 end_pos: _,
419 overlay_handle,
420 } => {
421 self.perform_lsp_rename(input, original_text, start_pos, overlay_handle);
422 }
423 PromptType::FileExplorerRename {
424 original_path,
425 original_name,
426 is_new_file,
427 } => {
428 self.perform_file_explorer_rename(original_path, original_name, input, is_new_file);
429 }
430 PromptType::ConfirmDeleteFile { path, is_dir } => {
431 let input_lower = input.trim().to_lowercase();
432 if input_lower == "y" || input_lower == "yes" {
433 self.perform_file_explorer_delete(path, is_dir);
434 } else {
435 self.set_status_message(t!("explorer.delete_cancelled").to_string());
436 }
437 }
438 PromptType::ConfirmLargeFileEncoding { path } => {
439 let input_lower = input.trim().to_lowercase();
440 let load_key = t!("file.large_encoding.key.load")
441 .to_string()
442 .to_lowercase();
443 let encoding_key = t!("file.large_encoding.key.encoding")
444 .to_string()
445 .to_lowercase();
446 let cancel_key = t!("file.large_encoding.key.cancel")
447 .to_string()
448 .to_lowercase();
449 if input_lower.is_empty() || input_lower == load_key {
451 if let Err(e) = self.open_file_large_encoding_confirmed(&path) {
452 self.set_status_message(
453 t!("file.error_opening", error = e.to_string()).to_string(),
454 );
455 }
456 } else if input_lower == encoding_key {
457 self.start_open_file_with_encoding_prompt(path);
459 } else if input_lower == cancel_key {
460 self.set_status_message(t!("file.open_cancelled").to_string());
461 } else {
462 if let Err(e) = self.open_file_large_encoding_confirmed(&path) {
464 self.set_status_message(
465 t!("file.error_opening", error = e.to_string()).to_string(),
466 );
467 }
468 }
469 }
470 PromptType::StopLspServer => {
471 self.handle_stop_lsp_server(&input);
472 }
473 PromptType::SelectTheme { .. } => {
474 self.apply_theme(input.trim());
475 }
476 PromptType::SelectKeybindingMap => {
477 self.apply_keybinding_map(input.trim());
478 }
479 PromptType::SelectCursorStyle => {
480 self.apply_cursor_style(input.trim());
481 }
482 PromptType::SelectLocale => {
483 self.apply_locale(input.trim());
484 }
485 PromptType::CopyWithFormattingTheme => {
486 self.copy_selection_with_theme(input.trim());
487 }
488 PromptType::SwitchToTab => {
489 if let Ok(id) = input.trim().parse::<usize>() {
490 self.switch_to_tab(BufferId(id));
491 }
492 }
493 PromptType::QueryReplaceConfirm => {
494 if let Some(c) = input.chars().next() {
497 if let Err(e) = self.handle_interactive_replace_key(c) {
498 tracing::warn!("Interactive replace failed: {}", e);
499 }
500 }
501 }
502 PromptType::AddRuler => {
503 self.handle_add_ruler(&input);
504 }
505 PromptType::RemoveRuler => {
506 self.handle_remove_ruler(&input);
507 }
508 PromptType::SetTabSize => {
509 self.handle_set_tab_size(&input);
510 }
511 PromptType::SetLineEnding => {
512 self.handle_set_line_ending(&input);
513 }
514 PromptType::SetEncoding => {
515 self.handle_set_encoding(&input);
516 }
517 PromptType::SetLanguage => {
518 self.handle_set_language(&input);
519 }
520 PromptType::ShellCommand { replace } => {
521 self.handle_shell_command(&input, replace);
522 }
523 PromptType::AsyncPrompt => {
524 if let Some(callback_id) = self.pending_async_prompt_callback.take() {
526 let json = serde_json::to_string(&input).unwrap_or_else(|_| "null".to_string());
528 self.plugin_manager.resolve_callback(callback_id, json);
529 }
530 }
531 }
532 PromptResult::Done
533 }
534
535 fn handle_save_file_as(&mut self, input: &str) {
537 let expanded_path = expand_tilde(input);
539 let full_path = if expanded_path.is_absolute() {
540 normalize_path(&expanded_path)
541 } else {
542 normalize_path(&self.working_dir.join(&expanded_path))
543 };
544
545 let current_file_path = self
547 .active_state()
548 .buffer
549 .file_path()
550 .map(|p| p.to_path_buf());
551 let is_different_file = current_file_path.as_ref() != Some(&full_path);
552
553 if is_different_file && full_path.is_file() {
554 let filename = full_path
556 .file_name()
557 .map(|n| n.to_string_lossy().to_string())
558 .unwrap_or_else(|| full_path.display().to_string());
559 self.start_prompt(
560 t!("buffer.overwrite_confirm", name = &filename).to_string(),
561 PromptType::ConfirmOverwriteFile { path: full_path },
562 );
563 return;
564 }
565
566 self.perform_save_file_as(full_path);
568 }
569
570 pub(crate) fn perform_save_file_as(&mut self, full_path: std::path::PathBuf) {
572 let before_idx = self.active_event_log().current_index();
573 let before_len = self.active_event_log().len();
574 tracing::debug!(
575 "SaveFileAs BEFORE: event_log index={}, len={}",
576 before_idx,
577 before_len
578 );
579
580 match self.active_state_mut().buffer.save_to_file(&full_path) {
581 Ok(()) => {
582 let after_save_idx = self.active_event_log().current_index();
583 let after_save_len = self.active_event_log().len();
584 tracing::debug!(
585 "SaveFileAs AFTER buffer.save_to_file: event_log index={}, len={}",
586 after_save_idx,
587 after_save_len
588 );
589
590 let metadata = BufferMetadata::with_file(full_path.clone(), &self.working_dir);
591 self.buffer_metadata.insert(self.active_buffer(), metadata);
592
593 let mut language_changed = false;
596 let mut new_language = String::new();
597 if let Some(state) = self.buffers.get_mut(&self.active_buffer()) {
598 if state.language == "text" {
599 let detected =
600 crate::primitives::detected_language::DetectedLanguage::from_path(
601 &full_path,
602 &self.grammar_registry,
603 &self.config.languages,
604 );
605 new_language = detected.name.clone();
606 state.apply_language(detected);
607 language_changed = new_language != "text";
608 }
609 }
610 if language_changed {
611 #[cfg(feature = "plugins")]
612 self.update_plugin_state_snapshot();
613 self.plugin_manager.run_hook(
614 "language_changed",
615 crate::services::plugins::hooks::HookArgs::LanguageChanged {
616 buffer_id: self.active_buffer(),
617 language: new_language,
618 },
619 );
620 }
621
622 self.active_event_log_mut().mark_saved();
623 tracing::debug!(
624 "SaveFileAs AFTER mark_saved: event_log index={}, len={}",
625 self.active_event_log().current_index(),
626 self.active_event_log().len()
627 );
628
629 if let Ok(metadata) = self.filesystem.metadata(&full_path) {
630 if let Some(mtime) = metadata.modified {
631 self.file_mod_times.insert(full_path.clone(), mtime);
632 }
633 }
634
635 self.notify_lsp_save();
636
637 self.emit_event(
638 crate::model::control_event::events::FILE_SAVED.name,
639 serde_json::json!({"path": full_path.display().to_string()}),
640 );
641
642 self.plugin_manager.run_hook(
643 "after_file_save",
644 crate::services::plugins::hooks::HookArgs::AfterFileSave {
645 buffer_id: self.active_buffer(),
646 path: full_path.clone(),
647 },
648 );
649
650 if let Some(buffer_to_close) = self.pending_close_buffer.take() {
651 if let Err(e) = self.force_close_buffer(buffer_to_close) {
652 self.set_status_message(
653 t!("file.saved_cannot_close", error = e.to_string()).to_string(),
654 );
655 } else {
656 self.set_status_message(t!("buffer.saved_and_closed").to_string());
657 }
658 } else {
659 self.set_status_message(
660 t!("file.saved_as", path = full_path.display().to_string()).to_string(),
661 );
662 }
663 }
664 Err(e) => {
665 self.pending_close_buffer = None;
666 self.set_status_message(t!("file.error_saving", error = e.to_string()).to_string());
667 }
668 }
669 }
670
671 fn handle_set_compose_width(&mut self, input: &str) {
673 let active_split = self.split_manager.active_split();
674 let trimmed = input.trim();
675
676 if trimmed.is_empty() {
677 if let Some(vs) = self.split_view_states.get_mut(&active_split) {
678 vs.compose_width = None;
679 }
680 self.set_status_message(t!("settings.compose_width_cleared").to_string());
681 } else {
682 match trimmed.parse::<u16>() {
683 Ok(val) if val > 0 => {
684 if let Some(vs) = self.split_view_states.get_mut(&active_split) {
685 vs.compose_width = Some(val);
686 }
687 self.set_status_message(
688 t!("settings.compose_width_set", value = val).to_string(),
689 );
690 }
691 _ => {
692 self.set_status_message(
693 t!("error.invalid_compose_width", input = input).to_string(),
694 );
695 }
696 }
697 }
698 }
699
700 fn handle_add_ruler(&mut self, input: &str) {
702 let trimmed = input.trim();
703 match trimmed.parse::<usize>() {
704 Ok(col) if col > 0 => {
705 let active_split = self.split_manager.active_split();
706 if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
707 if !view_state.rulers.contains(&col) {
708 view_state.rulers.push(col);
709 view_state.rulers.sort();
710 }
711 }
712 self.config.editor.rulers = self
714 .split_view_states
715 .get(&active_split)
716 .map(|vs| vs.rulers.clone())
717 .unwrap_or_default();
718 self.save_rulers_to_config();
719 self.set_status_message(t!("rulers.added", column = col).to_string());
720 }
721 Ok(_) => {
722 self.set_status_message(t!("rulers.must_be_positive").to_string());
723 }
724 Err(_) => {
725 self.set_status_message(t!("rulers.invalid_column", input = input).to_string());
726 }
727 }
728 }
729
730 fn handle_remove_ruler(&mut self, input: &str) {
732 let trimmed = input.trim();
733 if let Ok(col) = trimmed.parse::<usize>() {
734 let active_split = self.split_manager.active_split();
735 if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
736 view_state.rulers.retain(|&r| r != col);
737 }
738 self.config.editor.rulers = self
740 .split_view_states
741 .get(&active_split)
742 .map(|vs| vs.rulers.clone())
743 .unwrap_or_default();
744 self.save_rulers_to_config();
745 self.set_status_message(t!("rulers.removed", column = col).to_string());
746 }
747 }
748
749 fn save_rulers_to_config(&mut self) {
751 if let Err(e) = self.filesystem.create_dir_all(&self.dir_context.config_dir) {
752 tracing::warn!("Failed to create config directory: {}", e);
753 return;
754 }
755 let resolver = ConfigResolver::new(self.dir_context.clone(), self.working_dir.clone());
756 if let Err(e) = resolver.save_to_layer(&self.config, ConfigLayer::User) {
757 tracing::warn!("Failed to save rulers to config: {}", e);
758 }
759 }
760
761 fn handle_set_tab_size(&mut self, input: &str) {
763 let buffer_id = self.active_buffer();
764 let trimmed = input.trim();
765
766 match trimmed.parse::<usize>() {
767 Ok(val) if val > 0 => {
768 if let Some(state) = self.buffers.get_mut(&buffer_id) {
769 state.buffer_settings.tab_size = val;
770 }
771 self.set_status_message(t!("settings.tab_size_set", value = val).to_string());
772 }
773 Ok(_) => {
774 self.set_status_message(t!("settings.tab_size_positive").to_string());
775 }
776 Err(_) => {
777 self.set_status_message(t!("error.invalid_tab_size", input = input).to_string());
778 }
779 }
780 }
781
782 fn handle_set_line_ending(&mut self, input: &str) {
784 use crate::model::buffer::LineEnding;
785
786 let trimmed = input.trim();
788 let code = trimmed.split_whitespace().next().unwrap_or(trimmed);
789
790 let line_ending = match code.to_uppercase().as_str() {
791 "LF" => Some(LineEnding::LF),
792 "CRLF" => Some(LineEnding::CRLF),
793 "CR" => Some(LineEnding::CR),
794 _ => None,
795 };
796
797 match line_ending {
798 Some(le) => {
799 self.active_state_mut().buffer.set_line_ending(le);
800 self.set_status_message(
801 t!("settings.line_ending_set", value = le.display_name()).to_string(),
802 );
803 }
804 None => {
805 self.set_status_message(t!("error.unknown_line_ending", input = input).to_string());
806 }
807 }
808 }
809
810 fn handle_set_encoding(&mut self, input: &str) {
812 use crate::model::buffer::Encoding;
813
814 let trimmed = input.trim();
815
816 let encoding = Encoding::all()
819 .iter()
820 .find(|enc| enc.display_name().eq_ignore_ascii_case(trimmed))
821 .copied()
822 .or_else(|| {
823 let before_paren = trimmed.split('(').next().unwrap_or(trimmed).trim();
825 Encoding::all()
826 .iter()
827 .find(|enc| enc.display_name().eq_ignore_ascii_case(before_paren))
828 .copied()
829 });
830
831 match encoding {
832 Some(enc) => {
833 self.active_state_mut().buffer.set_encoding(enc);
834 self.set_status_message(format!("Encoding set to {}", enc.display_name()));
835 }
836 None => {
837 self.set_status_message(format!("Unknown encoding: {}", input));
838 }
839 }
840 }
841
842 fn handle_open_file_with_encoding(&mut self, path: &std::path::Path, input: &str) {
848 use crate::model::buffer::Encoding;
849 use crate::view::prompt::PromptType;
850
851 let trimmed = input.trim();
852
853 let encoding = Encoding::all()
855 .iter()
856 .find(|enc| enc.display_name().eq_ignore_ascii_case(trimmed))
857 .copied()
858 .or_else(|| {
859 let before_paren = trimmed.split('(').next().unwrap_or(trimmed).trim();
860 Encoding::all()
861 .iter()
862 .find(|enc| enc.display_name().eq_ignore_ascii_case(before_paren))
863 .copied()
864 });
865
866 match encoding {
867 Some(enc) => {
868 let threshold = self.config.editor.large_file_threshold_bytes as usize;
871 let file_size = self
872 .filesystem
873 .metadata(path)
874 .map(|m| m.size as usize)
875 .unwrap_or(0);
876
877 if file_size >= threshold && enc.requires_full_file_load() {
878 let size_mb = file_size as f64 / (1024.0 * 1024.0);
880 let load_key = t!("file.large_encoding.key.load").to_string();
881 let encoding_key = t!("file.large_encoding.key.encoding").to_string();
882 let cancel_key = t!("file.large_encoding.key.cancel").to_string();
883 let prompt_msg = t!(
884 "file.large_encoding_prompt",
885 encoding = enc.display_name(),
886 size = format!("{:.0}", size_mb),
887 load_key = load_key,
888 encoding_key = encoding_key,
889 cancel_key = cancel_key
890 )
891 .to_string();
892 self.start_prompt(
893 prompt_msg,
894 PromptType::ConfirmLargeFileEncoding {
895 path: path.to_path_buf(),
896 },
897 );
898 return;
899 }
900
901 self.key_context = crate::input::keybindings::KeyContext::Normal;
903
904 if let Err(e) = self.open_file_with_encoding(path, enc) {
906 self.set_status_message(
907 t!("file.error_opening", error = e.to_string()).to_string(),
908 );
909 } else {
910 self.set_status_message(format!(
911 "Opened {} with {} encoding",
912 path.display(),
913 enc.display_name()
914 ));
915 }
916 }
917 None => {
918 self.set_status_message(format!("Unknown encoding: {}", input));
919 }
920 }
921 }
922
923 fn handle_reload_with_encoding(&mut self, input: &str) {
926 use crate::model::buffer::Encoding;
927
928 let trimmed = input.trim();
929
930 let encoding = Encoding::all()
932 .iter()
933 .find(|enc| enc.display_name().eq_ignore_ascii_case(trimmed))
934 .copied()
935 .or_else(|| {
936 let before_paren = trimmed.split('(').next().unwrap_or(trimmed).trim();
937 Encoding::all()
938 .iter()
939 .find(|enc| enc.display_name().eq_ignore_ascii_case(before_paren))
940 .copied()
941 });
942
943 match encoding {
944 Some(enc) => {
945 if let Err(e) = self.reload_with_encoding(enc) {
947 self.set_status_message(format!("Failed to reload: {}", e));
948 } else {
949 self.set_status_message(format!(
950 "Reloaded with {} encoding",
951 enc.display_name()
952 ));
953 }
954 }
955 None => {
956 self.set_status_message(format!("Unknown encoding: {}", input));
957 }
958 }
959 }
960
961 pub(crate) fn resolve_language_id(&self, syntax_name: &str) -> Option<String> {
966 crate::primitives::detected_language::resolve_language_id(
967 syntax_name,
968 &self.grammar_registry,
969 &self.config.languages,
970 )
971 }
972
973 fn handle_set_language(&mut self, input: &str) {
975 use crate::primitives::detected_language::DetectedLanguage;
976
977 let trimmed = input.trim();
978
979 if trimmed == "Plain Text" || trimmed.to_lowercase() == "text" {
981 let buffer_id = self.active_buffer();
982 if let Some(state) = self.buffers.get_mut(&buffer_id) {
983 state.apply_language(DetectedLanguage::plain_text());
984 self.set_status_message("Language set to Plain Text".to_string());
985 }
986 #[cfg(feature = "plugins")]
987 self.update_plugin_state_snapshot();
988 self.plugin_manager.run_hook(
989 "language_changed",
990 crate::services::plugins::hooks::HookArgs::LanguageChanged {
991 buffer_id: self.active_buffer(),
992 language: "text".to_string(),
993 },
994 );
995 return;
996 }
997
998 if let Some(detected) = DetectedLanguage::from_syntax_name(
1000 trimmed,
1001 &self.grammar_registry,
1002 &self.config.languages,
1003 ) {
1004 let language = detected.name.clone();
1005 let buffer_id = self.active_buffer();
1006 if let Some(state) = self.buffers.get_mut(&buffer_id) {
1007 state.apply_language(detected);
1008 self.set_status_message(format!("Language set to {}", trimmed));
1009 }
1010 #[cfg(feature = "plugins")]
1011 self.update_plugin_state_snapshot();
1012 self.plugin_manager.run_hook(
1013 "language_changed",
1014 crate::services::plugins::hooks::HookArgs::LanguageChanged {
1015 buffer_id,
1016 language,
1017 },
1018 );
1019 } else {
1020 self.set_status_message(format!("Unknown language: {}", input));
1021 }
1022 }
1023
1024 fn handle_register_input<F>(&mut self, input: &str, action: F, register_type: &str)
1026 where
1027 F: FnOnce(&mut Self, char),
1028 {
1029 if let Some(c) = input.trim().chars().next() {
1030 if c.is_ascii_digit() {
1031 action(self, c);
1032 } else {
1033 self.set_status_message(
1034 t!("register.must_be_digit", "type" = register_type).to_string(),
1035 );
1036 }
1037 } else {
1038 self.set_status_message(t!("register.not_specified").to_string());
1039 }
1040 }
1041
1042 fn handle_confirm_close_buffer(&mut self, input: &str, buffer_id: BufferId) -> bool {
1044 let input_lower = input.trim().to_lowercase();
1045 let save_key = t!("prompt.key.save").to_string().to_lowercase();
1046 let discard_key = t!("prompt.key.discard").to_string().to_lowercase();
1047
1048 let first_char = input_lower.chars().next();
1049 let save_first = save_key.chars().next();
1050 let discard_first = discard_key.chars().next();
1051
1052 if first_char == save_first {
1053 let has_path = self
1055 .buffers
1056 .get(&buffer_id)
1057 .map(|s| s.buffer.file_path().is_some())
1058 .unwrap_or(false);
1059
1060 if has_path {
1061 let old_active = self.active_buffer();
1062 self.set_active_buffer(buffer_id);
1063 if let Err(e) = self.save() {
1064 self.set_status_message(
1065 t!("file.save_failed", error = e.to_string()).to_string(),
1066 );
1067 self.set_active_buffer(old_active);
1068 return true; }
1070 self.set_active_buffer(old_active);
1071 if let Err(e) = self.force_close_buffer(buffer_id) {
1072 self.set_status_message(
1073 t!("file.cannot_close", error = e.to_string()).to_string(),
1074 );
1075 } else {
1076 self.set_status_message(t!("buffer.saved_and_closed").to_string());
1077 }
1078 } else {
1079 self.pending_close_buffer = Some(buffer_id);
1080 self.start_prompt_with_initial_text(
1081 t!("file.save_as_prompt").to_string(),
1082 PromptType::SaveFileAs,
1083 String::new(),
1084 );
1085 }
1086 } else if first_char == discard_first {
1087 if let Err(e) = self.force_close_buffer(buffer_id) {
1089 self.set_status_message(t!("file.cannot_close", error = e.to_string()).to_string());
1090 } else {
1091 self.set_status_message(t!("buffer.changes_discarded").to_string());
1092 }
1093 } else {
1094 self.set_status_message(t!("buffer.close_cancelled").to_string());
1095 }
1096 false
1097 }
1098
1099 fn handle_stop_lsp_server(&mut self, input: &str) {
1101 let language = input.trim();
1102 if language.is_empty() {
1103 return;
1104 }
1105
1106 if let Some(lsp) = &mut self.lsp {
1107 if lsp.shutdown_server(language) {
1108 if let Some(lsp_config) = self.config.lsp.get_mut(language) {
1109 lsp_config.auto_start = false;
1110 if let Err(e) = self.save_config() {
1111 tracing::warn!(
1112 "Failed to save config after disabling LSP auto-start: {}",
1113 e
1114 );
1115 } else {
1116 let config_path = self.dir_context.config_path();
1117 self.emit_event(
1118 "config_changed",
1119 serde_json::json!({
1120 "path": config_path.to_string_lossy(),
1121 }),
1122 );
1123 }
1124 }
1125
1126 let buffer_ids: Vec<_> = self
1128 .buffers
1129 .iter()
1130 .filter(|(_, s)| s.language == language)
1131 .map(|(id, _)| *id)
1132 .collect();
1133 for buffer_id in buffer_ids {
1134 self.disable_lsp_for_buffer(buffer_id);
1135 }
1136
1137 self.set_status_message(t!("lsp.server_stopped", language = language).to_string());
1138 } else {
1139 self.set_status_message(
1140 t!("lsp.server_not_found", language = language).to_string(),
1141 );
1142 }
1143 }
1144 }
1145
1146 fn handle_quick_open_confirm(
1148 &mut self,
1149 input: &str,
1150 selected_index: Option<usize>,
1151 ) -> PromptResult {
1152 if let Some(query) = input.strip_prefix('>') {
1154 return self.handle_quick_open_command(query, selected_index);
1156 }
1157
1158 if let Some(query) = input.strip_prefix('#') {
1159 return self.handle_quick_open_buffer(query, selected_index);
1161 }
1162
1163 if let Some(line_str) = input.strip_prefix(':') {
1164 if let Ok(line_num) = line_str.parse::<usize>() {
1166 if line_num > 0 {
1167 self.goto_line_col(line_num, None);
1168 self.set_status_message(t!("goto.jumped", line = line_num).to_string());
1169 } else {
1170 self.set_status_message(t!("goto.line_must_be_positive").to_string());
1171 }
1172 } else {
1173 self.set_status_message(t!("error.invalid_line", input = line_str).to_string());
1174 }
1175 return PromptResult::Done;
1176 }
1177
1178 self.handle_quick_open_file(input, selected_index)
1180 }
1181
1182 fn handle_quick_open_command(
1184 &mut self,
1185 query: &str,
1186 selected_index: Option<usize>,
1187 ) -> PromptResult {
1188 let suggestions = {
1189 let registry = self.command_registry.read().unwrap();
1190 let selection_active = self.has_active_selection();
1191 let active_buffer_mode = self
1192 .buffer_metadata
1193 .get(&self.active_buffer())
1194 .and_then(|m| m.virtual_mode());
1195 let has_lsp_config = {
1196 let language = self
1197 .buffers
1198 .get(&self.active_buffer())
1199 .map(|s| s.language.as_str());
1200 language
1201 .and_then(|lang| self.lsp.as_ref().and_then(|lsp| lsp.get_config(lang)))
1202 .is_some()
1203 };
1204
1205 registry.filter(
1206 query,
1207 self.key_context,
1208 &self.keybindings,
1209 selection_active,
1210 &self.active_custom_contexts,
1211 active_buffer_mode,
1212 has_lsp_config,
1213 )
1214 };
1215
1216 if let Some(idx) = selected_index {
1217 if let Some(suggestion) = suggestions.get(idx) {
1218 if suggestion.disabled {
1219 self.set_status_message(t!("status.command_not_available").to_string());
1220 return PromptResult::Done;
1221 }
1222
1223 let commands = self.command_registry.read().unwrap().get_all();
1225 if let Some(cmd) = commands
1226 .iter()
1227 .find(|c| c.get_localized_name() == suggestion.text)
1228 {
1229 let action = cmd.action.clone();
1230 let cmd_name = cmd.get_localized_name();
1231 self.command_registry
1232 .write()
1233 .unwrap()
1234 .record_usage(&cmd_name);
1235 return PromptResult::ExecuteAction(action);
1236 }
1237 }
1238 }
1239
1240 self.set_status_message(t!("status.no_selection").to_string());
1241 PromptResult::Done
1242 }
1243
1244 fn handle_quick_open_buffer(
1246 &mut self,
1247 query: &str,
1248 selected_index: Option<usize>,
1249 ) -> PromptResult {
1250 let suggestions = self.get_buffer_suggestions(query);
1252
1253 if let Some(idx) = selected_index {
1254 if let Some(suggestion) = suggestions.get(idx) {
1255 if let Some(value) = &suggestion.value {
1256 if let Ok(buffer_id) = value.parse::<usize>() {
1257 let buffer_id = crate::model::event::BufferId(buffer_id);
1258 if self.buffers.contains_key(&buffer_id) {
1259 self.set_active_buffer(buffer_id);
1260 if let Some(name) = self.active_state().buffer.file_path() {
1261 self.set_status_message(
1262 t!("buffer.switched", name = name.display().to_string())
1263 .to_string(),
1264 );
1265 }
1266 return PromptResult::Done;
1267 }
1268 }
1269 }
1270 }
1271 }
1272
1273 self.set_status_message(t!("status.no_selection").to_string());
1274 PromptResult::Done
1275 }
1276
1277 fn open_file_with_jump(
1278 &mut self,
1279 full_path: std::path::PathBuf,
1280 line: Option<usize>,
1281 column: Option<usize>,
1282 ) {
1283 match self.open_file(&full_path) {
1284 Ok(_) => {
1285 if let Some(line) = line {
1286 self.goto_line_col(line, column);
1287 }
1288 self.set_status_message(
1289 t!("buffer.opened", name = full_path.display().to_string()).to_string(),
1290 );
1291 }
1292 Err(e) => {
1293 if let Some(confirmation) =
1295 e.downcast_ref::<crate::model::buffer::LargeFileEncodingConfirmation>()
1296 {
1297 self.start_large_file_encoding_confirmation(confirmation);
1298 } else {
1299 self.set_status_message(
1300 t!("file.error_opening", error = e.to_string()).to_string(),
1301 );
1302 }
1303 }
1304 }
1305 }
1306
1307 fn handle_quick_open_file(
1309 &mut self,
1310 input: &str,
1311 selected_index: Option<usize>,
1312 ) -> PromptResult {
1313 let (path_from_input, line, column) = parse_path_line_col(input);
1314 let suggestion_input = if path_from_input.is_empty() {
1317 input
1318 } else {
1319 &path_from_input
1320 };
1321 let suggestions = self.get_file_suggestions(suggestion_input);
1322
1323 if let Some(idx) = selected_index {
1324 if let Some(suggestion) = suggestions.get(idx) {
1325 if let Some(path_str) = &suggestion.value {
1326 let path = std::path::PathBuf::from(path_str);
1327 let full_path = if path.is_absolute() {
1328 path
1329 } else {
1330 self.working_dir.join(&path)
1331 };
1332
1333 self.file_provider.record_access(path_str);
1335
1336 self.open_file_with_jump(full_path, line, column);
1337 return PromptResult::Done;
1338 }
1339 }
1340 }
1341
1342 if line.is_some() && !path_from_input.is_empty() {
1343 let expanded_path = expand_tilde(&path_from_input);
1344 let full_path = if expanded_path.is_absolute() {
1345 expanded_path
1346 } else {
1347 self.working_dir.join(&expanded_path)
1348 };
1349
1350 self.file_provider.record_access(&path_from_input);
1352
1353 self.open_file_with_jump(full_path, line, column);
1354 return PromptResult::Done;
1355 }
1356
1357 self.set_status_message(t!("status.no_selection").to_string());
1358 PromptResult::Done
1359 }
1360}
1361
1362#[cfg(test)]
1367mod tests {
1368 use super::parse_path_line_col;
1369
1370 #[test]
1371 fn test_parse_path_line_col_empty() {
1372 let (path, line, col) = parse_path_line_col("");
1373 assert_eq!(path, "");
1374 assert_eq!(line, None);
1375 assert_eq!(col, None);
1376 }
1377
1378 #[test]
1379 fn test_parse_path_line_col_plain_path() {
1380 let (path, line, col) = parse_path_line_col("src/main.rs");
1381 assert_eq!(path, "src/main.rs");
1382 assert_eq!(line, None);
1383 assert_eq!(col, None);
1384 }
1385
1386 #[test]
1387 fn test_parse_path_line_col_line_only() {
1388 let (path, line, col) = parse_path_line_col("src/main.rs:42");
1389 assert_eq!(path, "src/main.rs");
1390 assert_eq!(line, Some(42));
1391 assert_eq!(col, None);
1392 }
1393
1394 #[test]
1395 fn test_parse_path_line_col_line_and_col() {
1396 let (path, line, col) = parse_path_line_col("src/main.rs:42:10");
1397 assert_eq!(path, "src/main.rs");
1398 assert_eq!(line, Some(42));
1399 assert_eq!(col, Some(10));
1400 }
1401
1402 #[test]
1403 fn test_parse_path_line_col_trimmed() {
1404 let (path, line, col) = parse_path_line_col(" src/main.rs:5:2 ");
1405 assert_eq!(path, "src/main.rs");
1406 assert_eq!(line, Some(5));
1407 assert_eq!(col, Some(2));
1408 }
1409
1410 #[test]
1411 fn test_parse_path_line_col_zero_line_rejected() {
1412 let (path, line, col) = parse_path_line_col("src/main.rs:0");
1413 assert_eq!(path, "src/main.rs:0");
1414 assert_eq!(line, None);
1415 assert_eq!(col, None);
1416 }
1417
1418 #[test]
1419 fn test_parse_path_line_col_zero_col_rejected() {
1420 let (path, line, col) = parse_path_line_col("src/main.rs:1:0");
1421 assert_eq!(path, "src/main.rs:1:0");
1422 assert_eq!(line, None);
1423 assert_eq!(col, None);
1424 }
1425
1426 #[cfg(windows)]
1427 #[test]
1428 fn test_parse_path_line_col_windows_drive() {
1429 let (path, line, col) = parse_path_line_col(r"C:\src\main.rs:12:3");
1430 assert_eq!(path, r"C:\src\main.rs");
1431 assert_eq!(line, Some(12));
1432 assert_eq!(col, Some(3));
1433 }
1434}