Skip to main content

fresh/app/
prompt_actions.rs

1//! Prompt confirmation action handlers.
2//!
3//! This module contains handlers for different prompt types when the user confirms input.
4
5use 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
17/// Result of handling a prompt confirmation.
18pub enum PromptResult {
19    /// Prompt handled, continue normally
20    Done,
21    /// Prompt handled, should execute this action next
22    ExecuteAction(Action),
23    /// Prompt handled, should return early from handle_action
24    EarlyReturn,
25}
26
27pub(super) fn parse_path_line_col(input: &str) -> (String, Option<usize>, Option<usize>) {
28    crate::input::quick_open::parse_path_line_col(input)
29}
30
31impl Editor {
32    /// Handle prompt confirmation based on the prompt type.
33    ///
34    /// Returns a `PromptResult` indicating what the caller should do next.
35    pub fn handle_prompt_confirm_input(
36        &mut self,
37        input: String,
38        prompt_type: PromptType,
39        selected_index: Option<usize>,
40    ) -> PromptResult {
41        match prompt_type {
42            PromptType::OpenFile => {
43                let (path_str, line, column) = parse_path_line_col(&input);
44                // Expand tilde to home directory first
45                let expanded_path = expand_tilde(&path_str);
46                let resolved_path = if expanded_path.is_absolute() {
47                    normalize_path(&expanded_path)
48                } else {
49                    normalize_path(&self.working_dir.join(&expanded_path))
50                };
51
52                self.open_file_with_jump(resolved_path, line, column);
53            }
54            PromptType::OpenFileWithEncoding { path } => {
55                self.handle_open_file_with_encoding(&path, &input);
56            }
57            PromptType::ReloadWithEncoding => {
58                self.handle_reload_with_encoding(&input);
59            }
60            PromptType::SwitchProject => {
61                // Expand tilde to home directory first
62                let expanded_path = expand_tilde(&input);
63                let resolved_path = if expanded_path.is_absolute() {
64                    normalize_path(&expanded_path)
65                } else {
66                    normalize_path(&self.working_dir.join(&expanded_path))
67                };
68
69                if resolved_path.is_dir() {
70                    self.change_working_dir(resolved_path);
71                } else {
72                    self.set_status_message(
73                        t!(
74                            "file.not_directory",
75                            path = resolved_path.display().to_string()
76                        )
77                        .to_string(),
78                    );
79                }
80            }
81            PromptType::SaveFileAs => {
82                self.handle_save_file_as(&input);
83            }
84            PromptType::Search => {
85                self.perform_search(&input);
86            }
87            PromptType::ReplaceSearch => {
88                self.perform_search(&input);
89                self.start_prompt(
90                    t!("replace.prompt", search = &input).to_string(),
91                    PromptType::Replace {
92                        search: input.clone(),
93                    },
94                );
95            }
96            PromptType::Replace { search } => {
97                if self.search_confirm_each {
98                    self.start_interactive_replace(&search, &input);
99                } else {
100                    self.perform_replace(&search, &input);
101                }
102            }
103            PromptType::QueryReplaceSearch => {
104                self.perform_search(&input);
105                self.start_prompt(
106                    t!("replace.query_prompt", search = &input).to_string(),
107                    PromptType::QueryReplace {
108                        search: input.clone(),
109                    },
110                );
111            }
112            PromptType::QueryReplace { search } => {
113                if self.search_confirm_each {
114                    self.start_interactive_replace(&search, &input);
115                } else {
116                    self.perform_replace(&search, &input);
117                }
118            }
119            PromptType::GotoLine => {
120                let input = input.trim();
121                let buffer_id = self.active_buffer();
122                if let Some(state) = self.buffers.get(&buffer_id) {
123                    let max_line = state.buffer.line_count().unwrap_or(1);
124                    if self.config.editor.relative_line_numbers {
125                        match input.parse::<isize>() {
126                            Ok(delta) if delta != 0 => {
127                                let current_line = state.primary_cursor_line_number.value() + 1;
128                                let target = if delta >= 0 {
129                                    current_line.saturating_add(delta as usize)
130                                } else {
131                                    current_line.saturating_sub(delta.unsigned_abs())
132                                };
133                                let target = target.clamp(1, max_line);
134                                self.goto_line_col(target, None);
135                                self.set_status_message(
136                                    t!("goto.jumped", line = target).to_string(),
137                                );
138                            }
139                            Ok(_) => {
140                                self.set_status_message(
141                                    t!("goto.line_must_be_positive").to_string(),
142                                );
143                            }
144                            Err(_) => {
145                                self.set_status_message(
146                                    t!("error.invalid_line", input = input).to_string(),
147                                );
148                            }
149                        }
150                    } else {
151                        match input.parse::<usize>() {
152                            Ok(line_num) if line_num > 0 => {
153                                let target = line_num.clamp(1, max_line);
154                                self.goto_line_col(target, None);
155                                self.set_status_message(
156                                    t!("goto.jumped", line = target).to_string(),
157                                );
158                            }
159                            Ok(_) => {
160                                self.set_status_message(
161                                    t!("goto.line_must_be_positive").to_string(),
162                                );
163                            }
164                            Err(_) => {
165                                self.set_status_message(
166                                    t!("error.invalid_line", input = input).to_string(),
167                                );
168                            }
169                        }
170                    }
171                } else {
172                    self.set_status_message(t!("status.no_selection").to_string());
173                }
174            }
175            PromptType::GotoByteOffset => {
176                // Parse byte offset — strip optional trailing 'B' or 'b' suffix
177                let trimmed = input.trim();
178                let num_str = trimmed
179                    .strip_suffix('B')
180                    .or_else(|| trimmed.strip_suffix('b'))
181                    .unwrap_or(trimmed);
182                match num_str.parse::<usize>() {
183                    Ok(offset) => {
184                        self.goto_byte_offset(offset);
185                        self.set_status_message(
186                            t!("goto.jumped_byte", offset = offset).to_string(),
187                        );
188                    }
189                    Err(_) => {
190                        self.set_status_message(
191                            t!("goto.invalid_byte_offset", input = &input).to_string(),
192                        );
193                    }
194                }
195            }
196            PromptType::GotoLineScanConfirm => {
197                let answer = input.trim().to_lowercase();
198                if answer == "y" || answer == "yes" {
199                    // Start incremental scan (non-blocking, updates progress in status bar)
200                    self.start_incremental_line_scan(true);
201                    // The GotoLine prompt will be opened when the scan completes
202                    // (in process_line_scan)
203                } else {
204                    // No scan — open byte offset prompt (exact byte navigation)
205                    self.start_prompt(
206                        t!("goto.byte_offset_prompt").to_string(),
207                        PromptType::GotoByteOffset,
208                    );
209                }
210            }
211            PromptType::QuickOpen => {
212                // Handle Quick Open confirmation based on prefix
213                return self.handle_quick_open_confirm(&input, selected_index);
214            }
215            PromptType::SetBackgroundFile => {
216                if let Err(e) = self.load_ansi_background(&input) {
217                    self.set_status_message(
218                        t!("error.background_load_failed", error = e.to_string()).to_string(),
219                    );
220                }
221            }
222            PromptType::SetBackgroundBlend => match input.trim().parse::<f32>() {
223                Ok(val) => {
224                    let clamped = val.clamp(0.0, 1.0);
225                    self.background_fade = clamped;
226                    self.set_status_message(
227                        t!(
228                            "error.background_blend_set",
229                            value = format!("{:.2}", clamped)
230                        )
231                        .to_string(),
232                    );
233                }
234                Err(_) => {
235                    self.set_status_message(t!("error.invalid_blend", input = &input).to_string());
236                }
237            },
238            PromptType::SetPageWidth => {
239                self.handle_set_page_width(&input);
240            }
241            PromptType::RecordMacro => {
242                self.handle_register_input(
243                    &input,
244                    |editor, c| editor.toggle_macro_recording(c),
245                    "Macro",
246                );
247            }
248            PromptType::PlayMacro => {
249                self.handle_register_input(&input, |editor, c| editor.play_macro(c), "Macro");
250            }
251            PromptType::SetBookmark => {
252                self.handle_register_input(&input, |editor, c| editor.set_bookmark(c), "Bookmark");
253            }
254            PromptType::JumpToBookmark => {
255                self.handle_register_input(
256                    &input,
257                    |editor, c| editor.jump_to_bookmark(c),
258                    "Bookmark",
259                );
260            }
261            PromptType::Plugin { custom_type } => {
262                tracing::info!(
263                    "prompt_confirmed: dispatching hook for prompt_type='{}', input='{}', selected_index={:?}",
264                    custom_type, input, selected_index
265                );
266                self.plugin_manager.run_hook(
267                    "prompt_confirmed",
268                    HookArgs::PromptConfirmed {
269                        prompt_type: custom_type.clone(),
270                        input,
271                        selected_index,
272                    },
273                );
274                tracing::info!(
275                    "prompt_confirmed: hook dispatched for prompt_type='{}'",
276                    custom_type
277                );
278            }
279            PromptType::ConfirmRevert => {
280                let input_lower = input.trim().to_lowercase();
281                let revert_key = t!("prompt.key.revert").to_string().to_lowercase();
282                if input_lower == revert_key || input_lower == "revert" {
283                    if let Err(e) = self.revert_file() {
284                        self.set_status_message(
285                            t!("file.revert_failed", error = e.to_string()).to_string(),
286                        );
287                    }
288                } else {
289                    self.set_status_message(t!("buffer.revert_cancelled").to_string());
290                }
291            }
292            PromptType::ConfirmSaveConflict => {
293                let input_lower = input.trim().to_lowercase();
294                if input_lower == "o" || input_lower == "overwrite" {
295                    if let Err(e) = self.save() {
296                        self.set_status_message(
297                            t!("file.save_failed", error = e.to_string()).to_string(),
298                        );
299                    }
300                } else {
301                    self.set_status_message(t!("buffer.save_cancelled").to_string());
302                }
303            }
304            PromptType::ConfirmSudoSave { info } => {
305                let input_lower = input.trim().to_lowercase();
306                if input_lower == "y" || input_lower == "yes" {
307                    // Hide prompt before starting blocking command to clear the line
308                    self.cancel_prompt();
309
310                    // Read temp file and write via sudo (works for both local and remote)
311                    let result = (|| -> anyhow::Result<()> {
312                        let data = self.authority.filesystem.read_file(&info.temp_path)?;
313                        self.authority.filesystem.sudo_write(
314                            &info.dest_path,
315                            &data,
316                            info.mode,
317                            info.uid,
318                            info.gid,
319                        )?;
320                        // Best-effort cleanup of temp file.
321                        #[allow(clippy::let_underscore_must_use)]
322                        let _ = self.authority.filesystem.remove_file(&info.temp_path);
323                        Ok(())
324                    })();
325
326                    match result {
327                        Ok(_) => {
328                            if let Err(e) = self
329                                .active_state_mut()
330                                .buffer
331                                .finalize_external_save(info.dest_path.clone())
332                            {
333                                tracing::warn!("Failed to finalize sudo save: {}", e);
334                                self.set_status_message(
335                                    t!("prompt.sudo_save_failed", error = e.to_string())
336                                        .to_string(),
337                                );
338                            } else if let Err(e) = self.finalize_save(Some(info.dest_path)) {
339                                tracing::warn!("Failed to finalize save after sudo: {}", e);
340                                self.set_status_message(
341                                    t!("prompt.sudo_save_failed", error = e.to_string())
342                                        .to_string(),
343                                );
344                            }
345                        }
346                        Err(e) => {
347                            tracing::warn!("Sudo save failed: {}", e);
348                            self.set_status_message(
349                                t!("prompt.sudo_save_failed", error = e.to_string()).to_string(),
350                            );
351                            // Best-effort cleanup of temp file.
352                            #[allow(clippy::let_underscore_must_use)]
353                            let _ = self.authority.filesystem.remove_file(&info.temp_path);
354                        }
355                    }
356                } else {
357                    self.set_status_message(t!("buffer.save_cancelled").to_string());
358                    // Best-effort cleanup of temp file.
359                    #[allow(clippy::let_underscore_must_use)]
360                    let _ = self.authority.filesystem.remove_file(&info.temp_path);
361                }
362            }
363            PromptType::ConfirmOverwriteFile { path } => {
364                let input_lower = input.trim().to_lowercase();
365                if input_lower == "o" || input_lower == "overwrite" {
366                    self.perform_save_file_as(path);
367                } else {
368                    self.set_status_message(t!("buffer.save_cancelled").to_string());
369                }
370            }
371            PromptType::ConfirmCreateDirectory { path } => {
372                let input_lower = input.trim().to_lowercase();
373                if input_lower == "c" || input_lower == "create" {
374                    if let Some(parent) = path.parent() {
375                        if let Err(e) = self.authority.filesystem.create_dir_all(parent) {
376                            self.set_status_message(
377                                t!("file.error_saving", error = e.to_string()).to_string(),
378                            );
379                            return PromptResult::Done;
380                        }
381                    }
382                    self.perform_save_file_as(path);
383                } else {
384                    self.set_status_message(t!("buffer.save_cancelled").to_string());
385                }
386            }
387            PromptType::ConfirmCloseBuffer { buffer_id } => {
388                if self.handle_confirm_close_buffer(&input, buffer_id) {
389                    return PromptResult::EarlyReturn;
390                }
391            }
392            PromptType::ConfirmQuitWithModified => {
393                if self.handle_confirm_quit_modified(&input) {
394                    return PromptResult::EarlyReturn;
395                }
396            }
397            PromptType::LspRename {
398                original_text,
399                start_pos,
400                end_pos: _,
401                overlay_handle,
402            } => {
403                self.perform_lsp_rename(input, original_text, start_pos, overlay_handle);
404            }
405            PromptType::FileExplorerRename {
406                original_path,
407                original_name,
408                is_new_file,
409            } => {
410                self.perform_file_explorer_rename(original_path, original_name, input, is_new_file);
411            }
412            PromptType::ConfirmDeleteFile { path, is_dir } => {
413                let input_lower = input.trim().to_lowercase();
414                if input_lower == "y" || input_lower == "yes" {
415                    self.perform_file_explorer_delete(path, is_dir);
416                } else {
417                    self.set_status_message(t!("explorer.delete_cancelled").to_string());
418                }
419            }
420            PromptType::ConfirmPasteConflict { src, dst, is_cut } => {
421                match input.trim().to_lowercase().as_str() {
422                    "o" | "overwrite" => {
423                        self.perform_file_explorer_paste(src, dst, is_cut);
424                    }
425                    "r" | "rename" => {
426                        let initial = dst
427                            .file_name()
428                            .map(|n| n.to_string_lossy().to_string())
429                            .unwrap_or_default();
430                        let dst_dir = dst
431                            .parent()
432                            .map(|p| p.to_path_buf())
433                            .unwrap_or_else(|| dst.clone());
434                        self.start_prompt_with_initial_text(
435                            t!("explorer.paste_rename_prompt").to_string(),
436                            PromptType::FileExplorerPasteRename {
437                                src,
438                                dst_dir,
439                                is_cut,
440                            },
441                            initial,
442                        );
443                    }
444                    "" | "c" | "cancel" => {
445                        self.set_status_message(t!("explorer.paste_cancelled").to_string());
446                    }
447                    _ => {
448                        // Unknown input — re-prompt rather than silently
449                        // cancel. Losing the clipboard on a typo is
450                        // frustrating; make the user explicitly pick a
451                        // valid choice.
452                        let name = crate::app::file_explorer::truncate_name_for_prompt(
453                            &dst.file_name().unwrap_or_default().to_string_lossy(),
454                            40,
455                        );
456                        self.start_prompt(
457                            t!("explorer.paste_conflict", name = &name).to_string(),
458                            PromptType::ConfirmPasteConflict { src, dst, is_cut },
459                        );
460                    }
461                }
462            }
463            PromptType::FileExplorerPasteRename {
464                src,
465                dst_dir,
466                is_cut,
467            } => {
468                if input.trim().is_empty() {
469                    self.set_status_message(t!("explorer.paste_cancelled").to_string());
470                    return PromptResult::Done;
471                }
472                let new_dst = dst_dir.join(input.trim());
473                if self.authority.filesystem.exists(&new_dst) {
474                    self.start_prompt(
475                        t!("explorer.paste_conflict", name = input.trim()).to_string(),
476                        PromptType::ConfirmPasteConflict {
477                            src,
478                            dst: new_dst,
479                            is_cut,
480                        },
481                    );
482                } else {
483                    self.perform_file_explorer_paste(src, new_dst, is_cut);
484                }
485            }
486            PromptType::ConfirmMultiDelete { paths } => {
487                let input_lower = input.trim().to_lowercase();
488                if input_lower == "y" || input_lower == "yes" {
489                    for path in paths {
490                        let is_dir = self.authority.filesystem.is_dir(&path).unwrap_or(false);
491                        self.perform_file_explorer_delete(path, is_dir);
492                    }
493                } else {
494                    self.set_status_message(t!("explorer.delete_cancelled").to_string());
495                }
496            }
497            PromptType::ConfirmMultiPasteConflict {
498                safe,
499                confirmed,
500                mut pending,
501                is_cut,
502            } => {
503                let (cur_src, cur_dst) = pending.remove(0);
504                // Case matters here: `o` / `s` act on the current conflict
505                // only, `O` / `S` act on all remaining conflicts. No other
506                // prompt uses case-sensitive input, so be strict about
507                // matching a single char; cancel on explicit `c`; re-prompt
508                // on anything else so a typo doesn't drop the whole batch.
509                match input.trim() {
510                    "o" | "overwrite" => {
511                        let mut new_confirmed = confirmed;
512                        new_confirmed.push((cur_src, cur_dst));
513                        if pending.is_empty() {
514                            self.execute_resolved_multi_paste(safe, new_confirmed, is_cut);
515                        } else {
516                            self.prompt_next_paste_conflict(safe, new_confirmed, pending, is_cut);
517                        }
518                    }
519                    "O" => {
520                        let mut new_confirmed = confirmed;
521                        new_confirmed.push((cur_src, cur_dst));
522                        new_confirmed.extend(pending);
523                        self.execute_resolved_multi_paste(safe, new_confirmed, is_cut);
524                    }
525                    "s" | "skip" => {
526                        if pending.is_empty() {
527                            self.execute_resolved_multi_paste(safe, confirmed, is_cut);
528                        } else {
529                            self.prompt_next_paste_conflict(safe, confirmed, pending, is_cut);
530                        }
531                    }
532                    "S" => {
533                        self.execute_resolved_multi_paste(safe, confirmed, is_cut);
534                    }
535                    "" | "c" | "cancel" => {
536                        self.set_status_message(t!("explorer.paste_cancelled").to_string());
537                    }
538                    _ => {
539                        // Unknown input — re-prompt for the CURRENT conflict
540                        // so a typo doesn't cancel the whole pending queue.
541                        let mut pending_with_current = vec![(cur_src, cur_dst)];
542                        pending_with_current.extend(pending);
543                        self.prompt_next_paste_conflict(
544                            safe,
545                            confirmed,
546                            pending_with_current,
547                            is_cut,
548                        );
549                    }
550                }
551            }
552            PromptType::ConfirmLargeFileEncoding { path } => {
553                let input_lower = input.trim().to_lowercase();
554                let load_key = t!("file.large_encoding.key.load")
555                    .to_string()
556                    .to_lowercase();
557                let encoding_key = t!("file.large_encoding.key.encoding")
558                    .to_string()
559                    .to_lowercase();
560                let cancel_key = t!("file.large_encoding.key.cancel")
561                    .to_string()
562                    .to_lowercase();
563                // Default (empty input or load key) loads the file
564                if input_lower.is_empty() || input_lower == load_key {
565                    if let Err(e) = self.open_file_large_encoding_confirmed(&path) {
566                        self.set_status_message(
567                            t!("file.error_opening", error = e.to_string()).to_string(),
568                        );
569                    }
570                } else if input_lower == encoding_key {
571                    // Let user pick a different encoding
572                    self.start_open_file_with_encoding_prompt(path);
573                } else if input_lower == cancel_key {
574                    self.set_status_message(t!("file.open_cancelled").to_string());
575                } else {
576                    // Unknown input - default to load
577                    if let Err(e) = self.open_file_large_encoding_confirmed(&path) {
578                        self.set_status_message(
579                            t!("file.error_opening", error = e.to_string()).to_string(),
580                        );
581                    }
582                }
583            }
584            PromptType::StopLspServer => {
585                self.handle_stop_lsp_server(&input);
586            }
587            PromptType::RestartLspServer => {
588                self.handle_restart_lsp_server(&input);
589            }
590            PromptType::SelectTheme { .. } => {
591                self.apply_theme(input.trim());
592            }
593            PromptType::SelectKeybindingMap => {
594                self.apply_keybinding_map(input.trim());
595            }
596            PromptType::SelectCursorStyle => {
597                self.apply_cursor_style(input.trim());
598            }
599            PromptType::SelectLocale => {
600                self.apply_locale(input.trim());
601            }
602            PromptType::CopyWithFormattingTheme => {
603                self.copy_selection_with_theme(input.trim());
604            }
605            PromptType::SwitchToTab => {
606                if let Ok(id) = input.trim().parse::<usize>() {
607                    self.switch_to_tab(BufferId(id));
608                }
609            }
610            PromptType::QueryReplaceConfirm => {
611                // This is handled by InsertChar, not PromptConfirm
612                // But if somehow Enter is pressed, treat it as skip (n)
613                if let Some(c) = input.chars().next() {
614                    if let Err(e) = self.handle_interactive_replace_key(c) {
615                        tracing::warn!("Interactive replace failed: {}", e);
616                    }
617                }
618            }
619            PromptType::AddRuler => {
620                self.handle_add_ruler(&input);
621            }
622            PromptType::RemoveRuler => {
623                self.handle_remove_ruler(&input);
624            }
625            PromptType::SetTabSize => {
626                self.handle_set_tab_size(&input);
627            }
628            PromptType::SetLineEnding => {
629                self.handle_set_line_ending(&input);
630            }
631            PromptType::SetEncoding => {
632                self.handle_set_encoding(&input);
633            }
634            PromptType::SetLanguage => {
635                self.handle_set_language(&input);
636            }
637            PromptType::ShellCommand { replace } => {
638                self.handle_shell_command(&input, replace);
639            }
640            PromptType::AsyncPrompt => {
641                // Resolve the pending async prompt callback with the input text
642                if let Some(callback_id) = self.pending_async_prompt_callback.take() {
643                    // Serialize the input as a JSON string
644                    let json = serde_json::to_string(&input).unwrap_or_else(|_| "null".to_string());
645                    self.plugin_manager.resolve_callback(callback_id, json);
646                }
647            }
648        }
649        PromptResult::Done
650    }
651
652    /// Handle SaveFileAs prompt confirmation.
653    fn handle_save_file_as(&mut self, input: &str) {
654        // Expand tilde to home directory first
655        let expanded_path = expand_tilde(input);
656        let full_path = if expanded_path.is_absolute() {
657            normalize_path(&expanded_path)
658        } else {
659            normalize_path(&self.working_dir.join(&expanded_path))
660        };
661
662        self.save_file_as_with_checks(full_path);
663    }
664
665    /// Check for overwrite/missing directory before saving, prompting if needed.
666    pub(crate) fn save_file_as_with_checks(&mut self, full_path: std::path::PathBuf) {
667        // Check if we're saving to a different file that already exists
668        let current_file_path = self
669            .active_state()
670            .buffer
671            .file_path()
672            .map(|p| p.to_path_buf());
673        let is_different_file = current_file_path.as_ref() != Some(&full_path);
674
675        if is_different_file && full_path.is_file() {
676            // File exists and is different from current - ask for confirmation
677            let filename = full_path
678                .file_name()
679                .map(|n| n.to_string_lossy().to_string())
680                .unwrap_or_else(|| full_path.display().to_string());
681            self.start_prompt(
682                t!("buffer.overwrite_confirm", name = &filename).to_string(),
683                PromptType::ConfirmOverwriteFile { path: full_path },
684            );
685            return;
686        }
687
688        // Check if parent directory exists
689        if let Some(parent) = full_path.parent() {
690            if !parent.as_os_str().is_empty() && !self.authority.filesystem.exists(parent) {
691                let dir_name = parent
692                    .strip_prefix(&self.working_dir)
693                    .unwrap_or(parent)
694                    .display()
695                    .to_string();
696                self.start_prompt(
697                    t!("buffer.create_directory_confirm", name = &dir_name).to_string(),
698                    PromptType::ConfirmCreateDirectory { path: full_path },
699                );
700                return;
701            }
702        }
703
704        // Proceed with save
705        self.perform_save_file_as(full_path);
706    }
707
708    /// Perform the actual SaveFileAs operation (called after confirmation if needed).
709    pub(crate) fn perform_save_file_as(&mut self, full_path: std::path::PathBuf) {
710        let before_idx = self.active_event_log().current_index();
711        let before_len = self.active_event_log().len();
712        tracing::debug!(
713            "SaveFileAs BEFORE: event_log index={}, len={}",
714            before_idx,
715            before_len
716        );
717
718        match self.active_state_mut().buffer.save_to_file(&full_path) {
719            Ok(()) => {
720                let after_save_idx = self.active_event_log().current_index();
721                let after_save_len = self.active_event_log().len();
722                tracing::debug!(
723                    "SaveFileAs AFTER buffer.save_to_file: event_log index={}, len={}",
724                    after_save_idx,
725                    after_save_len
726                );
727
728                let metadata = BufferMetadata::with_file(
729                    full_path.clone(),
730                    &full_path,
731                    &self.working_dir,
732                    self.authority.path_translation.as_ref(),
733                );
734                self.buffer_metadata.insert(self.active_buffer(), metadata);
735
736                // Auto-detect language if it's currently "text"
737                // This ensures syntax highlighting works immediately after "Save As"
738                let mut language_changed = false;
739                let mut new_language = String::new();
740                if let Some(state) = self.buffers.get_mut(&self.active_buffer()) {
741                    if state.language == "text" {
742                        let first_line = state.buffer.first_line_lossy();
743                        let detected =
744                            crate::primitives::detected_language::DetectedLanguage::from_path(
745                                &full_path,
746                                first_line.as_deref(),
747                                &self.grammar_registry,
748                                &self.config.languages,
749                            );
750                        new_language = detected.name.clone();
751                        state.apply_language(detected);
752                        language_changed = new_language != "text";
753                    }
754                }
755                if language_changed {
756                    #[cfg(feature = "plugins")]
757                    self.update_plugin_state_snapshot();
758                    self.plugin_manager.run_hook(
759                        "language_changed",
760                        crate::services::plugins::hooks::HookArgs::LanguageChanged {
761                            buffer_id: self.active_buffer(),
762                            language: new_language,
763                        },
764                    );
765                }
766
767                self.active_event_log_mut().mark_saved();
768                tracing::debug!(
769                    "SaveFileAs AFTER mark_saved: event_log index={}, len={}",
770                    self.active_event_log().current_index(),
771                    self.active_event_log().len()
772                );
773
774                if let Ok(metadata) = self.authority.filesystem.metadata(&full_path) {
775                    if let Some(mtime) = metadata.modified {
776                        self.file_mod_times.insert(full_path.clone(), mtime);
777                    }
778                }
779
780                self.notify_lsp_save();
781
782                self.emit_event(
783                    crate::model::control_event::events::FILE_SAVED.name,
784                    serde_json::json!({"path": full_path.display().to_string()}),
785                );
786
787                self.plugin_manager.run_hook(
788                    "after_file_save",
789                    crate::services::plugins::hooks::HookArgs::AfterFileSave {
790                        buffer_id: self.active_buffer(),
791                        path: full_path.clone(),
792                    },
793                );
794
795                if let Some(buffer_to_close) = self.pending_close_buffer.take() {
796                    if let Err(e) = self.force_close_buffer(buffer_to_close) {
797                        self.set_status_message(
798                            t!("file.saved_cannot_close", error = e.to_string()).to_string(),
799                        );
800                    } else {
801                        self.set_status_message(t!("buffer.saved_and_closed").to_string());
802                    }
803                } else {
804                    self.set_status_message(
805                        t!("file.saved_as", path = full_path.display().to_string()).to_string(),
806                    );
807                }
808            }
809            Err(e) => {
810                self.pending_close_buffer = None;
811                self.set_status_message(t!("file.error_saving", error = e.to_string()).to_string());
812            }
813        }
814    }
815
816    /// Handle SetPageWidth prompt confirmation.
817    fn handle_set_page_width(&mut self, input: &str) {
818        let active_split = self.split_manager.active_split();
819        let trimmed = input.trim();
820
821        if trimmed.is_empty() {
822            if let Some(vs) = self.split_view_states.get_mut(&active_split) {
823                vs.compose_width = None;
824            }
825            self.set_status_message(t!("settings.page_width_cleared").to_string());
826        } else {
827            match trimmed.parse::<u16>() {
828                Ok(val) if val > 0 => {
829                    if let Some(vs) = self.split_view_states.get_mut(&active_split) {
830                        vs.compose_width = Some(val);
831                    }
832                    self.set_status_message(t!("settings.page_width_set", value = val).to_string());
833                }
834                _ => {
835                    self.set_status_message(
836                        t!("error.invalid_page_width", input = input).to_string(),
837                    );
838                }
839            }
840        }
841    }
842
843    /// Handle AddRuler prompt confirmation.
844    fn handle_add_ruler(&mut self, input: &str) {
845        let trimmed = input.trim();
846        match trimmed.parse::<usize>() {
847            Ok(col) if col > 0 => {
848                let active_split = self.split_manager.active_split();
849                if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
850                    if !view_state.rulers.contains(&col) {
851                        view_state.rulers.push(col);
852                        view_state.rulers.sort();
853                    }
854                }
855                // Persist to user config
856                let new_rulers = self
857                    .split_view_states
858                    .get(&active_split)
859                    .map(|vs| vs.rulers.clone())
860                    .unwrap_or_default();
861                self.config_mut().editor.rulers = new_rulers;
862                self.save_rulers_to_config();
863                self.set_status_message(t!("rulers.added", column = col).to_string());
864            }
865            Ok(_) => {
866                self.set_status_message(t!("rulers.must_be_positive").to_string());
867            }
868            Err(_) => {
869                self.set_status_message(t!("rulers.invalid_column", input = input).to_string());
870            }
871        }
872    }
873
874    /// Handle RemoveRuler prompt confirmation.
875    fn handle_remove_ruler(&mut self, input: &str) {
876        let trimmed = input.trim();
877        if let Ok(col) = trimmed.parse::<usize>() {
878            let active_split = self.split_manager.active_split();
879            if let Some(view_state) = self.split_view_states.get_mut(&active_split) {
880                view_state.rulers.retain(|&r| r != col);
881            }
882            // Persist to user config
883            let new_rulers = self
884                .split_view_states
885                .get(&active_split)
886                .map(|vs| vs.rulers.clone())
887                .unwrap_or_default();
888            self.config_mut().editor.rulers = new_rulers;
889            self.save_rulers_to_config();
890            self.set_status_message(t!("rulers.removed", column = col).to_string());
891        }
892    }
893
894    /// Save the current rulers setting to the user's config file
895    fn save_rulers_to_config(&mut self) {
896        if let Err(e) = self
897            .authority
898            .filesystem
899            .create_dir_all(&self.dir_context.config_dir)
900        {
901            tracing::warn!("Failed to create config directory: {}", e);
902            return;
903        }
904        let resolver = ConfigResolver::new(self.dir_context.clone(), self.working_dir.clone());
905        if let Err(e) = resolver.save_to_layer(&self.config, ConfigLayer::User) {
906            tracing::warn!("Failed to save rulers to config: {}", e);
907        }
908    }
909
910    /// Handle SetTabSize prompt confirmation.
911    fn handle_set_tab_size(&mut self, input: &str) {
912        let buffer_id = self.active_buffer();
913        let trimmed = input.trim();
914
915        match trimmed.parse::<usize>() {
916            Ok(val) if val > 0 => {
917                if let Some(state) = self.buffers.get_mut(&buffer_id) {
918                    state.buffer_settings.tab_size = val;
919                }
920                self.set_status_message(t!("settings.tab_size_set", value = val).to_string());
921            }
922            Ok(_) => {
923                self.set_status_message(t!("settings.tab_size_positive").to_string());
924            }
925            Err(_) => {
926                self.set_status_message(t!("error.invalid_tab_size", input = input).to_string());
927            }
928        }
929    }
930
931    /// Handle SetLineEnding prompt confirmation.
932    fn handle_set_line_ending(&mut self, input: &str) {
933        use crate::model::buffer::LineEnding;
934
935        // Extract the line ending code from the input (e.g., "LF" from "LF (Unix/Linux/Mac)")
936        let trimmed = input.trim();
937        let code = trimmed.split_whitespace().next().unwrap_or(trimmed);
938
939        let line_ending = match code.to_uppercase().as_str() {
940            "LF" => Some(LineEnding::LF),
941            "CRLF" => Some(LineEnding::CRLF),
942            "CR" => Some(LineEnding::CR),
943            _ => None,
944        };
945
946        match line_ending {
947            Some(le) => {
948                self.active_state_mut().buffer.set_line_ending(le);
949                self.set_status_message(
950                    t!("settings.line_ending_set", value = le.display_name()).to_string(),
951                );
952            }
953            None => {
954                self.set_status_message(t!("error.unknown_line_ending", input = input).to_string());
955            }
956        }
957    }
958
959    /// Handle SetEncoding prompt confirmation.
960    fn handle_set_encoding(&mut self, input: &str) {
961        use crate::model::buffer::Encoding;
962
963        let trimmed = input.trim();
964
965        // First try to match the full input against encoding display names
966        // This handles multi-word names like "UTF-16 LE" and "UTF-8 BOM"
967        let encoding = Encoding::all()
968            .iter()
969            .find(|enc| enc.display_name().eq_ignore_ascii_case(trimmed))
970            .copied()
971            .or_else(|| {
972                // If no match, try extracting before the parenthesis (e.g., "UTF-8" from "UTF-8 (Unicode)")
973                let before_paren = trimmed.split('(').next().unwrap_or(trimmed).trim();
974                Encoding::all()
975                    .iter()
976                    .find(|enc| enc.display_name().eq_ignore_ascii_case(before_paren))
977                    .copied()
978            });
979
980        match encoding {
981            Some(enc) => {
982                self.active_state_mut().buffer.set_encoding(enc);
983                self.set_status_message(format!("Encoding set to {}", enc.display_name()));
984            }
985            None => {
986                self.set_status_message(format!("Unknown encoding: {}", input));
987            }
988        }
989    }
990
991    /// Handle OpenFileWithEncoding prompt confirmation.
992    /// Opens a file with a specific encoding (no auto-detection).
993    ///
994    /// For large files with non-resynchronizable encodings, shows a confirmation prompt
995    /// before loading the entire file into memory.
996    fn handle_open_file_with_encoding(&mut self, path: &std::path::Path, input: &str) {
997        use crate::model::buffer::Encoding;
998        use crate::view::prompt::PromptType;
999
1000        let trimmed = input.trim();
1001
1002        // Parse the encoding from input
1003        let encoding = Encoding::all()
1004            .iter()
1005            .find(|enc| enc.display_name().eq_ignore_ascii_case(trimmed))
1006            .copied()
1007            .or_else(|| {
1008                let before_paren = trimmed.split('(').next().unwrap_or(trimmed).trim();
1009                Encoding::all()
1010                    .iter()
1011                    .find(|enc| enc.display_name().eq_ignore_ascii_case(before_paren))
1012                    .copied()
1013            });
1014
1015        match encoding {
1016            Some(enc) => {
1017                // Check if this is a large file with non-resynchronizable encoding
1018                // If so, show confirmation prompt before loading
1019                let threshold = self.config.editor.large_file_threshold_bytes as usize;
1020                let file_size = self
1021                    .authority
1022                    .filesystem
1023                    .metadata(path)
1024                    .map(|m| m.size as usize)
1025                    .unwrap_or(0);
1026
1027                if file_size >= threshold && enc.requires_full_file_load() {
1028                    // Show confirmation prompt for large file with non-resynchronizable encoding
1029                    let size_mb = file_size as f64 / (1024.0 * 1024.0);
1030                    let load_key = t!("file.large_encoding.key.load").to_string();
1031                    let encoding_key = t!("file.large_encoding.key.encoding").to_string();
1032                    let cancel_key = t!("file.large_encoding.key.cancel").to_string();
1033                    let prompt_msg = t!(
1034                        "file.large_encoding_prompt",
1035                        encoding = enc.display_name(),
1036                        size = format!("{:.0}", size_mb),
1037                        load_key = load_key,
1038                        encoding_key = encoding_key,
1039                        cancel_key = cancel_key
1040                    )
1041                    .to_string();
1042                    self.start_prompt(
1043                        prompt_msg,
1044                        PromptType::ConfirmLargeFileEncoding {
1045                            path: path.to_path_buf(),
1046                        },
1047                    );
1048                    return;
1049                }
1050
1051                // Reset key context to Normal so editor gets focus
1052                self.key_context = crate::input::keybindings::KeyContext::Normal;
1053
1054                // Open the file with the specified encoding
1055                if let Err(e) = self.open_file_with_encoding(path, enc) {
1056                    self.set_status_message(
1057                        t!("file.error_opening", error = e.to_string()).to_string(),
1058                    );
1059                } else {
1060                    self.set_status_message(format!(
1061                        "Opened {} with {} encoding",
1062                        path.display(),
1063                        enc.display_name()
1064                    ));
1065                }
1066            }
1067            None => {
1068                self.set_status_message(format!("Unknown encoding: {}", input));
1069            }
1070        }
1071    }
1072
1073    /// Handle ReloadWithEncoding prompt confirmation.
1074    /// Reloads the current file with a specific encoding.
1075    fn handle_reload_with_encoding(&mut self, input: &str) {
1076        use crate::model::buffer::Encoding;
1077
1078        let trimmed = input.trim();
1079
1080        // Parse the encoding from input
1081        let encoding = Encoding::all()
1082            .iter()
1083            .find(|enc| enc.display_name().eq_ignore_ascii_case(trimmed))
1084            .copied()
1085            .or_else(|| {
1086                let before_paren = trimmed.split('(').next().unwrap_or(trimmed).trim();
1087                Encoding::all()
1088                    .iter()
1089                    .find(|enc| enc.display_name().eq_ignore_ascii_case(before_paren))
1090                    .copied()
1091            });
1092
1093        match encoding {
1094            Some(enc) => {
1095                // Reload the file with the specified encoding
1096                if let Err(e) = self.reload_with_encoding(enc) {
1097                    self.set_status_message(format!("Failed to reload: {}", e));
1098                } else {
1099                    self.set_status_message(format!(
1100                        "Reloaded with {} encoding",
1101                        enc.display_name()
1102                    ));
1103                }
1104            }
1105            None => {
1106                self.set_status_message(format!("Unknown encoding: {}", input));
1107            }
1108        }
1109    }
1110
1111    /// Handle SetLanguage prompt confirmation.
1112    fn handle_set_language(&mut self, input: &str) {
1113        use crate::primitives::detected_language::DetectedLanguage;
1114
1115        let trimmed = input.trim();
1116
1117        // Check for "Plain Text" (no highlighting)
1118        if trimmed == "Plain Text" || trimmed.to_lowercase() == "text" {
1119            let buffer_id = self.active_buffer();
1120            if let Some(state) = self.buffers.get_mut(&buffer_id) {
1121                state.apply_language(DetectedLanguage::plain_text());
1122                self.set_status_message("Language set to Plain Text".to_string());
1123            }
1124            #[cfg(feature = "plugins")]
1125            self.update_plugin_state_snapshot();
1126            self.plugin_manager.run_hook(
1127                "language_changed",
1128                crate::services::plugins::hooks::HookArgs::LanguageChanged {
1129                    buffer_id: self.active_buffer(),
1130                    language: "text".to_string(),
1131                },
1132            );
1133            return;
1134        }
1135
1136        // Try to find the syntax by name and resolve canonical language ID from config
1137        if let Some(detected) = DetectedLanguage::from_syntax_name(
1138            trimmed,
1139            &self.grammar_registry,
1140            &self.config.languages,
1141        ) {
1142            let language = detected.name.clone();
1143            let buffer_id = self.active_buffer();
1144            if let Some(state) = self.buffers.get_mut(&buffer_id) {
1145                state.apply_language(detected);
1146                self.set_status_message(format!("Language set to {}", trimmed));
1147            }
1148            #[cfg(feature = "plugins")]
1149            self.update_plugin_state_snapshot();
1150            self.plugin_manager.run_hook(
1151                "language_changed",
1152                crate::services::plugins::hooks::HookArgs::LanguageChanged {
1153                    buffer_id,
1154                    language,
1155                },
1156            );
1157        } else {
1158            // apply_language_config ensures user-configured languages (even
1159            // without a backing grammar, like a bare "fish" entry) appear in
1160            // the catalog, so from_syntax_name already handles that case.
1161            self.set_status_message(format!("Unknown language: {}", input));
1162        }
1163    }
1164
1165    /// Handle register-based input (macros, bookmarks).
1166    fn handle_register_input<F>(&mut self, input: &str, action: F, register_type: &str)
1167    where
1168        F: FnOnce(&mut Self, char),
1169    {
1170        if let Some(c) = input.trim().chars().next() {
1171            if c.is_ascii_digit() {
1172                action(self, c);
1173            } else {
1174                self.set_status_message(
1175                    t!("register.must_be_digit", "type" = register_type).to_string(),
1176                );
1177            }
1178        } else {
1179            self.set_status_message(t!("register.not_specified").to_string());
1180        }
1181    }
1182
1183    /// Handle ConfirmCloseBuffer prompt. Returns true if early return is needed.
1184    fn handle_confirm_close_buffer(&mut self, input: &str, buffer_id: BufferId) -> bool {
1185        let input_lower = input.trim().to_lowercase();
1186        let save_key = t!("prompt.key.save").to_string().to_lowercase();
1187        let discard_key = t!("prompt.key.discard").to_string().to_lowercase();
1188
1189        let first_char = input_lower.chars().next();
1190        let save_first = save_key.chars().next();
1191        let discard_first = discard_key.chars().next();
1192
1193        if first_char == save_first {
1194            // Save and close
1195            let has_path = self
1196                .buffers
1197                .get(&buffer_id)
1198                .map(|s| s.buffer.file_path().is_some())
1199                .unwrap_or(false);
1200
1201            if has_path {
1202                let old_active = self.active_buffer();
1203                self.set_active_buffer(buffer_id);
1204                if let Err(e) = self.save() {
1205                    self.set_status_message(
1206                        t!("file.save_failed", error = e.to_string()).to_string(),
1207                    );
1208                    self.set_active_buffer(old_active);
1209                    return true; // Early return
1210                }
1211                self.set_active_buffer(old_active);
1212                if let Err(e) = self.force_close_buffer(buffer_id) {
1213                    self.set_status_message(
1214                        t!("file.cannot_close", error = e.to_string()).to_string(),
1215                    );
1216                } else {
1217                    self.set_status_message(t!("buffer.saved_and_closed").to_string());
1218                }
1219            } else {
1220                self.pending_close_buffer = Some(buffer_id);
1221                self.start_prompt_with_initial_text(
1222                    t!("file.save_as_prompt").to_string(),
1223                    PromptType::SaveFileAs,
1224                    String::new(),
1225                );
1226            }
1227        } else if first_char == discard_first {
1228            // Discard and close
1229            if let Err(e) = self.force_close_buffer(buffer_id) {
1230                self.set_status_message(t!("file.cannot_close", error = e.to_string()).to_string());
1231            } else {
1232                self.set_status_message(t!("buffer.changes_discarded").to_string());
1233            }
1234        } else {
1235            self.set_status_message(t!("buffer.close_cancelled").to_string());
1236        }
1237        false
1238    }
1239
1240    /// Handle ConfirmQuitWithModified prompt. Returns true if early return is needed.
1241    fn handle_confirm_quit_modified(&mut self, input: &str) -> bool {
1242        let input_lower = input.trim().to_lowercase();
1243        let save_key = t!("prompt.key.save").to_string().to_lowercase();
1244        let discard_key = t!("prompt.key.discard").to_string().to_lowercase();
1245        let quit_key = t!("prompt.key.quit").to_string().to_lowercase();
1246
1247        let first_char = input_lower.chars().next();
1248        let save_first = save_key.chars().next();
1249        let discard_first = discard_key.chars().next();
1250        let quit_first = quit_key.chars().next();
1251
1252        if first_char == save_first {
1253            // Save all modified file-backed buffers to disk, then quit
1254            match self.save_all_on_exit() {
1255                Ok(count) => {
1256                    tracing::info!("Saved {} buffer(s) on exit", count);
1257                    self.should_quit = true;
1258                }
1259                Err(e) => {
1260                    self.set_status_message(
1261                        t!("file.save_failed", error = e.to_string()).to_string(),
1262                    );
1263                    return true; // Early return, stay in editor
1264                }
1265            }
1266        } else if first_char == discard_first {
1267            // Discard changes and quit (no recovery)
1268            self.should_quit = true;
1269        } else if first_char == quit_first && self.config.editor.hot_exit {
1270            // Quit without saving — changes will be preserved via hot exit recovery
1271            self.should_quit = true;
1272        } else {
1273            // Cancel (default)
1274            self.set_status_message(t!("buffer.close_cancelled").to_string());
1275        }
1276        false
1277    }
1278
1279    /// Handle StopLspServer prompt confirmation.
1280    ///
1281    /// Input format: `"language"` (stops all servers) or `"language/server_name"`
1282    /// (stops a specific server).
1283    pub fn handle_stop_lsp_server(&mut self, input: &str) {
1284        let input = input.trim();
1285        if input.is_empty() {
1286            return;
1287        }
1288
1289        // Parse "language/server_name" or just "language"
1290        let (language, server_name) = if let Some((lang, name)) = input.split_once('/') {
1291            (lang, Some(name))
1292        } else {
1293            (input, None)
1294        };
1295
1296        let has_server = self
1297            .lsp
1298            .as_ref()
1299            .is_some_and(|lsp| lsp.has_handles(language));
1300
1301        if !has_server {
1302            self.set_status_message(t!("lsp.server_not_found", language = language).to_string());
1303            return;
1304        }
1305
1306        // Check how many servers remain for this language after the stop.
1307        // If we're stopping a specific server and others remain, we should
1308        // only send didClose to that server, not disable LSP for the buffers.
1309        let stopping_all = server_name.is_none()
1310            || self
1311                .lsp
1312                .as_ref()
1313                .map(|lsp| lsp.handle_count(language) <= 1)
1314                .unwrap_or(true);
1315
1316        if stopping_all {
1317            // Send didClose for all buffers of this language BEFORE shutting
1318            // down the server, so the notifications reach the still-running
1319            // server and its handles are still present. `disable_lsp_for_buffer`
1320            // also marks the buffer's metadata as user-disabled and clears
1321            // per-URI stored diagnostics, which is what we want when the
1322            // user has asked for the server to go away entirely.
1323            let buffer_ids: Vec<_> = self
1324                .buffers
1325                .iter()
1326                .filter(|(_, s)| s.language == language)
1327                .map(|(id, _)| *id)
1328                .collect();
1329            for buffer_id in buffer_ids {
1330                self.disable_lsp_for_buffer(buffer_id);
1331            }
1332        } else if let Some(name) = server_name {
1333            // Send didClose only to the specific server being stopped.
1334            // The shared helper below handles clearing this server's
1335            // diagnostics.
1336            self.send_did_close_to_server(language, name);
1337        }
1338
1339        // Shutdown + clear lsp_server_statuses + clear diagnostics in one
1340        // step. Without the status clear the indicator stayed stuck at
1341        // "LSP (on)" after stop (reported 2026-04-13).
1342        let stopped = self.stop_lsp_server_and_cleanup(language, server_name);
1343
1344        if !stopped {
1345            self.set_status_message(t!("lsp.server_not_found", language = language).to_string());
1346            return;
1347        }
1348
1349        // Update config: disable auto_start for the stopped server(s)
1350        if let Some(lsp_configs) = self.config_mut().lsp.get_mut(language) {
1351            for c in lsp_configs.as_mut_slice() {
1352                if let Some(name) = server_name {
1353                    // Only disable auto_start for the specific server
1354                    if c.display_name() == name {
1355                        c.auto_start = false;
1356                    }
1357                } else {
1358                    c.auto_start = false;
1359                }
1360            }
1361            if let Err(e) = self.save_config() {
1362                tracing::warn!(
1363                    "Failed to save config after disabling LSP auto-start: {}",
1364                    e
1365                );
1366            } else {
1367                let config_path = self.dir_context.config_path();
1368                self.emit_event(
1369                    "config_changed",
1370                    serde_json::json!({
1371                        "path": config_path.to_string_lossy(),
1372                    }),
1373                );
1374            }
1375        }
1376
1377        let display = server_name.unwrap_or(language);
1378        self.set_status_message(t!("lsp.server_stopped", language = display).to_string());
1379    }
1380
1381    /// Handle RestartLspServer prompt confirmation.
1382    ///
1383    /// Input format: `"language"` (restarts all enabled servers) or
1384    /// `"language/server_name"` (restarts a specific server).
1385    pub fn handle_restart_lsp_server(&mut self, input: &str) {
1386        let input = input.trim();
1387        if input.is_empty() {
1388            return;
1389        }
1390
1391        // Parse "language/server_name" or just "language"
1392        let (language, server_name) = if let Some((lang, name)) = input.split_once('/') {
1393            (lang, Some(name))
1394        } else {
1395            (input, None)
1396        };
1397
1398        // Get file_path from active buffer for workspace root detection
1399        let buffer_id = self.active_buffer();
1400        let file_path = self
1401            .buffer_metadata
1402            .get(&buffer_id)
1403            .and_then(|meta| meta.file_path().cloned());
1404
1405        let (success, message) = if let Some(name) = server_name {
1406            // Restart a specific server
1407            if let Some(lsp) = self.lsp.as_mut() {
1408                lsp.manual_restart_server(language, name, file_path.as_deref())
1409            } else {
1410                (false, t!("lsp.no_manager").to_string())
1411            }
1412        } else {
1413            // Restart all enabled servers for the language
1414            if let Some(lsp) = self.lsp.as_mut() {
1415                lsp.manual_restart(language, file_path.as_deref())
1416            } else {
1417                (false, t!("lsp.no_manager").to_string())
1418            }
1419        };
1420
1421        self.status_message = Some(message);
1422
1423        if success {
1424            self.reopen_buffers_for_language(language);
1425        }
1426    }
1427
1428    /// Handle Quick Open prompt confirmation by dispatching through the provider registry
1429    fn handle_quick_open_confirm(
1430        &mut self,
1431        input: &str,
1432        selected_index: Option<usize>,
1433    ) -> PromptResult {
1434        use crate::input::quick_open::QuickOpenResult;
1435
1436        let context = self.build_quick_open_context();
1437        let result = if let Some((provider, query)) =
1438            self.quick_open_registry.get_provider_for_input(input)
1439        {
1440            // Resolve the selected suggestion once, so providers don't recompute
1441            let suggestions = provider.suggestions(query, &context);
1442            let selected = selected_index.and_then(|i| suggestions.get(i));
1443            provider.on_select(selected, query, &context)
1444        } else {
1445            QuickOpenResult::None
1446        };
1447
1448        self.execute_quick_open_result(result)
1449    }
1450
1451    /// Map a QuickOpenResult to a PromptResult, executing any necessary side effects
1452    fn execute_quick_open_result(
1453        &mut self,
1454        result: crate::input::quick_open::QuickOpenResult,
1455    ) -> PromptResult {
1456        use crate::input::quick_open::QuickOpenResult;
1457
1458        // Any live goto-line preview must be resolved before executing the
1459        // result: a GotoLine confirm accepts the preview as-is, everything
1460        // else (file/buffer/action/etc.) should see the pre-preview state.
1461        match &result {
1462            QuickOpenResult::GotoLine(_) => {
1463                // Commit the preview: discard the saved snapshot without
1464                // restoring, since the cursor is already at the target.
1465                self.goto_line_preview = None;
1466            }
1467            _ => {
1468                self.restore_goto_line_preview_snapshot();
1469            }
1470        }
1471
1472        match result {
1473            QuickOpenResult::ExecuteAction(action) => PromptResult::ExecuteAction(action),
1474            QuickOpenResult::OpenFile { path, line, column } => {
1475                let expanded_path = expand_tilde(&path);
1476                let full_path = if expanded_path.is_absolute() {
1477                    expanded_path
1478                } else {
1479                    self.working_dir.join(&expanded_path)
1480                };
1481                self.open_file_with_jump(full_path, line, column);
1482                PromptResult::Done
1483            }
1484            QuickOpenResult::ShowBuffer(buffer_id) => {
1485                let buffer_id = crate::model::event::BufferId(buffer_id);
1486                if self.buffers.contains_key(&buffer_id) {
1487                    self.set_active_buffer(buffer_id);
1488                    if let Some(name) = self.active_state().buffer.file_path() {
1489                        self.set_status_message(
1490                            t!("buffer.switched", name = name.display().to_string()).to_string(),
1491                        );
1492                    }
1493                }
1494                PromptResult::Done
1495            }
1496            QuickOpenResult::GotoLine(line) => {
1497                let buffer_id = self.active_buffer();
1498                if let Some(state) = self.buffers.get(&buffer_id) {
1499                    let max_line = state.buffer.line_count().unwrap_or(1);
1500                    if self.config.editor.relative_line_numbers {
1501                        let current_line = state.primary_cursor_line_number.value() + 1;
1502                        let delta = line;
1503                        let target = if delta >= 0 {
1504                            current_line.saturating_add(delta as usize)
1505                        } else {
1506                            current_line.saturating_sub(delta.unsigned_abs())
1507                        };
1508                        let target = target.clamp(1, max_line);
1509                        self.goto_line_col(target, None);
1510                        self.set_status_message(t!("goto.jumped", line = target).to_string());
1511                    } else {
1512                        let target = line.unsigned_abs().clamp(1, max_line);
1513                        self.goto_line_col(target, None);
1514                        self.set_status_message(t!("goto.jumped", line = target).to_string());
1515                    }
1516                } else {
1517                    self.set_status_message(t!("status.no_selection").to_string());
1518                }
1519                PromptResult::Done
1520            }
1521            QuickOpenResult::None => {
1522                self.set_status_message(t!("status.no_selection").to_string());
1523                PromptResult::Done
1524            }
1525            QuickOpenResult::Error(msg) => {
1526                self.set_status_message(msg);
1527                PromptResult::Done
1528            }
1529        }
1530    }
1531
1532    fn open_file_with_jump(
1533        &mut self,
1534        full_path: std::path::PathBuf,
1535        line: Option<usize>,
1536        column: Option<usize>,
1537    ) {
1538        match self.open_file(&full_path) {
1539            Ok(_) => {
1540                if let Some(line) = line {
1541                    self.goto_line_col(line, column);
1542                }
1543                self.set_status_message(
1544                    t!("buffer.opened", name = full_path.display().to_string()).to_string(),
1545                );
1546            }
1547            Err(e) => {
1548                // Check if this is a large file encoding confirmation error
1549                if let Some(confirmation) =
1550                    e.downcast_ref::<crate::model::buffer::LargeFileEncodingConfirmation>()
1551                {
1552                    self.start_large_file_encoding_confirmation(confirmation);
1553                } else {
1554                    self.set_status_message(
1555                        t!("file.error_opening", error = e.to_string()).to_string(),
1556                    );
1557                }
1558            }
1559        }
1560    }
1561
1562    /// Show the next per-conflict prompt in a multi-paste conflict chain.
1563    fn prompt_next_paste_conflict(
1564        &mut self,
1565        safe: Vec<(std::path::PathBuf, std::path::PathBuf)>,
1566        confirmed: Vec<(std::path::PathBuf, std::path::PathBuf)>,
1567        pending: Vec<(std::path::PathBuf, std::path::PathBuf)>,
1568        is_cut: bool,
1569    ) {
1570        let name = crate::app::file_explorer::truncate_name_for_prompt(
1571            &pending[0]
1572                .1
1573                .file_name()
1574                .unwrap_or_default()
1575                .to_string_lossy(),
1576            40,
1577        );
1578        self.start_prompt(
1579            t!("explorer.paste_conflict_multi", name = &name).to_string(),
1580            PromptType::ConfirmMultiPasteConflict {
1581                safe,
1582                confirmed,
1583                pending,
1584                is_cut,
1585            },
1586        );
1587    }
1588}
1589
1590// ---------------------------------------------------------------------------
1591// Tests
1592// ---------------------------------------------------------------------------
1593
1594#[cfg(test)]
1595mod tests {
1596    use super::parse_path_line_col;
1597
1598    #[test]
1599    fn test_parse_path_line_col_empty() {
1600        let (path, line, col) = parse_path_line_col("");
1601        assert_eq!(path, "");
1602        assert_eq!(line, None);
1603        assert_eq!(col, None);
1604    }
1605
1606    #[test]
1607    fn test_parse_path_line_col_plain_path() {
1608        let (path, line, col) = parse_path_line_col("src/main.rs");
1609        assert_eq!(path, "src/main.rs");
1610        assert_eq!(line, None);
1611        assert_eq!(col, None);
1612    }
1613
1614    #[test]
1615    fn test_parse_path_line_col_line_only() {
1616        let (path, line, col) = parse_path_line_col("src/main.rs:42");
1617        assert_eq!(path, "src/main.rs");
1618        assert_eq!(line, Some(42));
1619        assert_eq!(col, None);
1620    }
1621
1622    #[test]
1623    fn test_parse_path_line_col_line_and_col() {
1624        let (path, line, col) = parse_path_line_col("src/main.rs:42:10");
1625        assert_eq!(path, "src/main.rs");
1626        assert_eq!(line, Some(42));
1627        assert_eq!(col, Some(10));
1628    }
1629
1630    #[test]
1631    fn test_parse_path_line_col_trimmed() {
1632        let (path, line, col) = parse_path_line_col("  src/main.rs:5:2  ");
1633        assert_eq!(path, "src/main.rs");
1634        assert_eq!(line, Some(5));
1635        assert_eq!(col, Some(2));
1636    }
1637
1638    #[test]
1639    fn test_parse_path_line_col_zero_line_rejected() {
1640        let (path, line, col) = parse_path_line_col("src/main.rs:0");
1641        assert_eq!(path, "src/main.rs:0");
1642        assert_eq!(line, None);
1643        assert_eq!(col, None);
1644    }
1645
1646    #[test]
1647    fn test_parse_path_line_col_zero_col_rejected() {
1648        let (path, line, col) = parse_path_line_col("src/main.rs:1:0");
1649        assert_eq!(path, "src/main.rs:1:0");
1650        assert_eq!(line, None);
1651        assert_eq!(col, None);
1652    }
1653
1654    #[cfg(windows)]
1655    #[test]
1656    fn test_parse_path_line_col_windows_drive() {
1657        let (path, line, col) = parse_path_line_col(r"C:\src\main.rs:12:3");
1658        assert_eq!(path, r"C:\src\main.rs");
1659        assert_eq!(line, Some(12));
1660        assert_eq!(col, Some(3));
1661    }
1662}