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