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