Skip to main content

fresh/app/
prompt_actions.rs

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