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