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