Skip to main content

fresh/app/
prompt_actions.rs

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