Skip to main content

fresh/app/
lsp_actions.rs

1//! LSP-related action handlers.
2//!
3//! This module contains handlers for LSP actions that require complex logic,
4//! such as restarting LSP servers and managing server lifecycle.
5
6use super::Editor;
7use crate::input::commands::Suggestion;
8use crate::model::event::BufferId;
9use crate::view::prompt::{Prompt, PromptType};
10use rust_i18n::t;
11
12impl Editor {
13    /// Handle the LspRestart action.
14    ///
15    /// For a single-server config, restarts immediately (no prompt).
16    /// For multiple servers, shows a prompt to select which server(s) to restart.
17    pub fn handle_lsp_restart(&mut self) {
18        // Get the language and file path from the active buffer
19        let buffer_id = self.active_buffer();
20        let Some(state) = self.buffers.get(&buffer_id) else {
21            return;
22        };
23        let language = state.language.clone();
24        let file_path = self
25            .buffer_metadata
26            .get(&buffer_id)
27            .and_then(|meta| meta.file_path().cloned());
28
29        // Get configured servers for this language
30        let configs: Vec<_> = self
31            .lsp
32            .as_ref()
33            .and_then(|lsp| lsp.get_configs(&language))
34            .map(|c| c.to_vec())
35            .unwrap_or_default();
36
37        if configs.is_empty() {
38            self.set_status_message(t!("lsp.no_server_configured").to_string());
39            return;
40        }
41
42        // Single server: restart immediately without a prompt (backward compat)
43        if configs.len() == 1 {
44            let Some(lsp) = self.lsp.as_mut() else {
45                self.set_status_message(t!("lsp.no_manager").to_string());
46                return;
47            };
48
49            let (success, message) = lsp.manual_restart(&language, file_path.as_deref());
50            self.status_message = Some(message);
51
52            if success {
53                self.reopen_buffers_for_language(&language);
54            }
55            return;
56        }
57
58        // Multiple servers: show a prompt
59        let mut suggestions: Vec<Suggestion> = Vec::new();
60
61        // Default option: restart all enabled servers
62        let enabled_names: Vec<_> = configs
63            .iter()
64            .filter(|c| c.enabled && !c.command.is_empty())
65            .map(|c| c.display_name())
66            .collect();
67        let all_description = if enabled_names.is_empty() {
68            Some("No enabled servers".to_string())
69        } else {
70            Some(enabled_names.join(", "))
71        };
72        suggestions.push(Suggestion {
73            text: format!("{} (all enabled)", language),
74            description: all_description,
75            value: Some(language.clone()),
76            disabled: enabled_names.is_empty(),
77            keybinding: None,
78            source: None,
79        });
80
81        // Individual server options
82        for config in &configs {
83            if config.command.is_empty() {
84                continue;
85            }
86            let name = config.display_name();
87            let status = if config.enabled { "" } else { " [disabled]" };
88            suggestions.push(Suggestion {
89                text: format!("{}/{}{}", language, name, status),
90                description: Some(format!("Command: {}", config.command)),
91                value: Some(format!("{}/{}", language, name)),
92                disabled: false,
93                keybinding: None,
94                source: None,
95            });
96        }
97
98        // Start prompt with suggestions
99        self.prompt = Some(Prompt::with_suggestions(
100            "Restart LSP server: ".to_string(),
101            PromptType::RestartLspServer,
102            suggestions.clone(),
103        ));
104
105        // Configure initial selection
106        if let Some(prompt) = self.prompt.as_mut() {
107            prompt.selected_suggestion = Some(0);
108        }
109    }
110
111    /// Send didOpen notifications for all buffers of a given language to any
112    /// server handles that haven't received them yet.
113    ///
114    /// Called after an LSP server starts or restarts so it immediately knows
115    /// about every open file (rather than waiting for the next user edit).
116    pub(crate) fn reopen_buffers_for_language(&mut self, language: &str) {
117        // Collect buffer info first to avoid borrow conflicts
118        // Use buffer's stored language rather than detecting from path
119        let buffers_for_language: Vec<_> = self
120            .buffers
121            .iter()
122            .filter_map(|(buf_id, state)| {
123                if state.language == language {
124                    self.buffer_metadata
125                        .get(buf_id)
126                        .and_then(|meta| meta.file_path().map(|p| (*buf_id, p.clone())))
127                } else {
128                    None
129                }
130            })
131            .collect();
132
133        let enable_inlay_hints = self.config.editor.enable_inlay_hints;
134
135        for (buffer_id, buf_path) in buffers_for_language {
136            let Some(state) = self.buffers.get(&buffer_id) else {
137                continue;
138            };
139
140            let Some(content) = state.buffer.to_string() else {
141                continue; // Skip buffers that aren't fully loaded
142            };
143
144            let Some(uri) = super::types::file_path_to_lsp_uri(&buf_path) else {
145                continue;
146            };
147
148            let lang_id = state.language.clone();
149            let line_count = state.buffer.line_count().unwrap_or(1000);
150            let buffer_version = state.buffer.version();
151
152            if let Some(lsp) = self.lsp.as_mut() {
153                // Respect auto_start setting for this user action
154                use crate::services::lsp::manager::LspSpawnResult;
155                if lsp.try_spawn(&lang_id, Some(&buf_path)) != LspSpawnResult::Spawned {
156                    continue;
157                }
158
159                // Collect handles that need didOpen (not yet tracked in
160                // lsp_opened_with for this buffer).
161                let opened_with = self
162                    .buffer_metadata
163                    .get(&buffer_id)
164                    .map(|m| m.lsp_opened_with.clone())
165                    .unwrap_or_default();
166
167                let handles_needing_open: Vec<(String, u64)> = lsp
168                    .get_handles(&lang_id)
169                    .into_iter()
170                    .filter(|sh| !opened_with.contains(&sh.handle.id()))
171                    .map(|sh| (sh.name.clone(), sh.handle.id()))
172                    .collect();
173
174                // Send didOpen to each handle that hasn't seen this buffer yet
175                for (name, handle_id) in handles_needing_open {
176                    let sh = lsp
177                        .get_handles_mut(&lang_id)
178                        .into_iter()
179                        .find(|s| s.handle.id() == handle_id);
180
181                    if let Some(sh) = sh {
182                        if let Err(e) =
183                            sh.handle
184                                .did_open(uri.clone(), content.clone(), lang_id.clone())
185                        {
186                            tracing::warn!("LSP did_open to '{}' failed: {}", name, e);
187                        } else if let Some(metadata) = self.buffer_metadata.get_mut(&buffer_id) {
188                            metadata.lsp_opened_with.insert(handle_id);
189                        }
190                    }
191                }
192            }
193
194            // Kick off inlay hints for this buffer right after (re)opening.
195            // Servers that emit a `serverQuiescent` notification (e.g.
196            // rust-analyzer) will refresh these later once indexing is
197            // done, but servers that don't would otherwise never get a
198            // hints request unless the user edits the buffer.
199            if enable_inlay_hints {
200                if let Some(lsp) = self.lsp.as_mut() {
201                    if let Some(sh) =
202                        lsp.handle_for_feature_mut(&lang_id, crate::types::LspFeature::InlayHints)
203                    {
204                        let request_id = self.next_lsp_request_id;
205                        self.next_lsp_request_id += 1;
206                        let last_line = line_count.saturating_sub(1) as u32;
207                        if let Err(e) =
208                            sh.handle
209                                .inlay_hints(request_id, uri.clone(), 0, 0, last_line, 10000)
210                        {
211                            tracing::debug!(
212                                "Failed to request inlay hints for {}: {}",
213                                uri.as_str(),
214                                e
215                            );
216                        } else {
217                            self.pending_inlay_hints_requests.insert(
218                                request_id,
219                                super::InlayHintsRequest {
220                                    buffer_id,
221                                    version: buffer_version,
222                                },
223                            );
224                        }
225                    }
226                }
227            }
228        }
229    }
230
231    /// Handle the LspStop action.
232    ///
233    /// Shows a prompt to select which LSP server to stop, with suggestions
234    /// for all currently running servers.
235    pub fn handle_lsp_stop(&mut self) {
236        let running_languages: Vec<String> = self
237            .lsp
238            .as_ref()
239            .map(|lsp| lsp.running_servers())
240            .unwrap_or_default();
241
242        if running_languages.is_empty() {
243            self.set_status_message(t!("lsp.no_servers_running").to_string());
244            return;
245        }
246
247        // Build suggestions showing server names when multiple servers per language
248        let mut suggestions: Vec<Suggestion> = Vec::new();
249        for lang in &running_languages {
250            let server_names: Vec<String> = self
251                .lsp
252                .as_ref()
253                .map(|lsp| lsp.server_names_for_language(lang))
254                .unwrap_or_default();
255
256            if server_names.len() > 1 {
257                // Multiple servers: show each individually
258                for name in &server_names {
259                    let description = Some(format!("Server: {}", name));
260                    suggestions.push(Suggestion {
261                        text: format!("{}/{}", lang, name),
262                        description,
263                        // Value carries "language/server_name" so the handler
264                        // knows exactly which server to stop.
265                        value: Some(format!("{}/{}", lang, name)),
266                        disabled: false,
267                        keybinding: None,
268                        source: None,
269                    });
270                }
271            } else {
272                // Single server: show language only (value = just language)
273                let description = self
274                    .lsp
275                    .as_ref()
276                    .and_then(|lsp| lsp.get_config(lang))
277                    .filter(|c| !c.command.is_empty())
278                    .map(|c| format!("Command: {}", c.command));
279
280                suggestions.push(Suggestion {
281                    text: lang.clone(),
282                    description,
283                    value: Some(lang.clone()),
284                    disabled: false,
285                    keybinding: None,
286                    source: None,
287                });
288            }
289        }
290
291        // Start prompt with suggestions
292        self.prompt = Some(Prompt::with_suggestions(
293            "Stop LSP server: ".to_string(),
294            PromptType::StopLspServer,
295            suggestions.clone(),
296        ));
297
298        // Configure initial selection
299        if let Some(prompt) = self.prompt.as_mut() {
300            if suggestions.len() == 1 {
301                // If only one entry, pre-fill the input with it
302                prompt.input = suggestions[0].text.clone();
303                prompt.cursor_pos = prompt.input.len();
304                prompt.selected_suggestion = Some(0);
305            } else if !prompt.suggestions.is_empty() {
306                // Auto-select first suggestion
307                prompt.selected_suggestion = Some(0);
308            }
309        }
310    }
311
312    /// Handle the LspToggleForBuffer action.
313    ///
314    /// Toggles LSP on/off for the current buffer only.
315    /// Requires an LSP server to be configured for the current buffer's language.
316    pub fn handle_lsp_toggle_for_buffer(&mut self) {
317        let buffer_id = self.active_buffer();
318
319        // Get the buffer's language to check if LSP is configured
320        let language = {
321            let Some(state) = self.buffers.get(&buffer_id) else {
322                return;
323            };
324            state.language.clone()
325        };
326
327        // Check if LSP is configured for this language
328        let lsp_configured = self
329            .lsp
330            .as_ref()
331            .and_then(|lsp| lsp.get_config(&language))
332            .is_some();
333
334        if !lsp_configured {
335            self.set_status_message(t!("lsp.no_server_configured").to_string());
336            return;
337        }
338
339        // Check current LSP state
340        let (was_enabled, file_path) = {
341            let Some(metadata) = self.buffer_metadata.get(&buffer_id) else {
342                return;
343            };
344            (metadata.lsp_enabled, metadata.file_path().cloned())
345        };
346
347        if was_enabled {
348            self.disable_lsp_for_buffer(buffer_id);
349        } else {
350            self.enable_lsp_for_buffer(buffer_id, &language, file_path);
351        }
352    }
353
354    /// Is the given language currently user-dismissed via the LSP popup?
355    pub fn is_lsp_language_user_dismissed(&self, language: &str) -> bool {
356        self.user_dismissed_lsp_languages.contains(language)
357    }
358
359    /// Dismiss the LSP pill for a language until the next editor session
360    /// (or until the user re-enables it from the popup). See docs on
361    /// `Editor::user_dismissed_lsp_languages` for the rationale.
362    pub fn dismiss_lsp_language(&mut self, language: &str) {
363        self.user_dismissed_lsp_languages
364            .insert(language.to_string());
365    }
366
367    /// Undo a previous dismissal — the pill returns to the normal
368    /// yellow `LSP (off)` for this language.
369    pub fn undismiss_lsp_language(&mut self, language: &str) {
370        self.user_dismissed_lsp_languages.remove(language);
371    }
372
373    /// Handle an action from the LSP status details popup.
374    ///
375    /// Action keys have the format:
376    /// - `restart:<language>/<server_name>` — restart a specific server
377    /// - `start:<language>` — start LSP server(s) for a language
378    /// - `stop:<language>/<server_name>` — stop a specific server
379    /// - `log:<language>` — open the LSP log file for the language
380    /// - `dismiss:<language>` — hide the pill for this language (dim style)
381    /// - `enable:<language>` — restore a dismissed language's pill
382    pub fn handle_lsp_status_action(&mut self, action_key: &str) {
383        if let Some(language) = action_key.strip_prefix("start:") {
384            // Start/restart LSP for this language (same as the "Start/Restart LSP" command)
385            let file_path = self
386                .buffer_metadata
387                .get(&self.active_buffer())
388                .and_then(|meta| meta.file_path().cloned());
389
390            if let Some(lsp) = self.lsp.as_mut() {
391                let (_, message) = lsp.manual_restart(language, file_path.as_deref());
392                self.status_message = Some(message);
393            } else {
394                self.status_message = Some("No LSP manager available".to_string());
395            }
396            self.reopen_buffers_for_language(language);
397        } else if let Some(target) = action_key.strip_prefix("restart:") {
398            // Parse language/server_name
399            if let Some((language, server_name)) = target.split_once('/') {
400                let file_path = self
401                    .buffer_metadata
402                    .get(&self.active_buffer())
403                    .and_then(|meta| meta.file_path().cloned());
404
405                if let Some(lsp) = self.lsp.as_mut() {
406                    // Shutdown the specific server first, then re-spawn
407                    lsp.shutdown_server_by_name(language, server_name);
408                }
409                // Remove the status entry so it gets re-created on spawn
410                self.lsp_server_statuses
411                    .remove(&(language.to_string(), server_name.to_string()));
412                if let Some(lsp) = self.lsp.as_mut() {
413                    let _ = lsp.manual_restart(language, file_path.as_deref());
414                }
415                self.reopen_buffers_for_language(language);
416                self.status_message = Some(format!(
417                    "Restarting LSP server: {}/{}",
418                    language, server_name
419                ));
420            }
421        } else if let Some(target) = action_key.strip_prefix("stop:") {
422            if let Some((language, server_name)) = target.split_once('/') {
423                // Send didClose first so the server drops documents
424                // cleanly; the shared helper then shuts the handle,
425                // clears lsp_server_statuses (so the status-bar pill
426                // flips back off), and clears diagnostics this server
427                // published. The old inline path missed the didClose
428                // and the diagnostic clear.
429                self.send_did_close_to_server(language, server_name);
430                let stopped = self.stop_lsp_server_and_cleanup(language, Some(server_name));
431                if stopped {
432                    self.status_message =
433                        Some(format!("Stopped LSP server: {}/{}", language, server_name));
434                } else {
435                    self.status_message = Some(format!(
436                        "LSP server not running: {}/{}",
437                        language, server_name
438                    ));
439                }
440            }
441        } else if let Some(language) = action_key.strip_prefix("log:") {
442            let log_path = crate::services::log_dirs::lsp_log_path(language);
443            if log_path.exists() {
444                match self.open_local_file(&log_path) {
445                    Ok(buffer_id) => {
446                        self.mark_buffer_read_only(buffer_id, true);
447                    }
448                    Err(e) => {
449                        self.status_message = Some(format!("Failed to open LSP log: {}", e));
450                    }
451                }
452            } else {
453                self.status_message = Some(format!("No log file found for {}", language));
454            }
455        } else if let Some(language) = action_key.strip_prefix("dismiss:") {
456            self.dismiss_lsp_language(language);
457            self.status_message = Some(format!(
458                "LSP pill dimmed for {}. Click it to re-enable.",
459                language
460            ));
461        } else if let Some(language) = action_key.strip_prefix("enable:") {
462            self.undismiss_lsp_language(language);
463            self.status_message = Some(format!("LSP pill restored for {}.", language));
464        }
465    }
466
467    /// Toggle folding at the current cursor position.
468    pub fn toggle_fold_at_cursor(&mut self) {
469        let buffer_id = self.active_buffer();
470        let pos = self.active_cursors().primary().position;
471        self.toggle_fold_at_byte(buffer_id, pos);
472    }
473
474    /// Toggle folding for the given line in the specified buffer.
475    ///
476    /// Kept for callers that only have a line number (e.g. gutter clicks
477    /// that already resolved the line).  Converts to a byte position and
478    /// delegates to [`Self::toggle_fold_at_byte`].
479    pub fn toggle_fold_at_line(&mut self, buffer_id: BufferId, line: usize) {
480        let byte_pos = {
481            let Some(state) = self.buffers.get(&buffer_id) else {
482                return;
483            };
484            state.buffer.line_start_offset(line).unwrap_or_else(|| {
485                use crate::view::folding::indent_folding;
486                let approx = line * state.buffer.estimated_line_length();
487                indent_folding::find_line_start_byte(&state.buffer, approx)
488            })
489        };
490        self.toggle_fold_at_byte(buffer_id, byte_pos);
491    }
492
493    /// Toggle folding at the given byte position in the specified buffer.
494    pub fn toggle_fold_at_byte(&mut self, buffer_id: BufferId, byte_pos: usize) {
495        let split_id = self.split_manager.active_split();
496        let (buffers, split_view_states) = (&mut self.buffers, &mut self.split_view_states);
497
498        let Some(state) = buffers.get_mut(&buffer_id) else {
499            return;
500        };
501
502        let Some(view_state) = split_view_states.get_mut(&split_id) else {
503            return;
504        };
505        let buf_state = view_state.ensure_buffer_state(buffer_id);
506
507        // Try to unfold first — check if this byte's line is a fold header.
508        let header_byte = {
509            use crate::view::folding::indent_folding;
510            indent_folding::find_line_start_byte(&state.buffer, byte_pos)
511        };
512        if buf_state
513            .folds
514            .remove_by_header_byte(&state.buffer, &mut state.marker_list, header_byte)
515        {
516            return;
517        }
518
519        // Also unfold if the byte position is inside an existing fold.
520        if buf_state
521            .folds
522            .remove_if_contains_byte(&mut state.marker_list, byte_pos)
523        {
524            return;
525        }
526
527        // Determine the fold byte range: prefer LSP ranges, fall back to indent-based.
528        if !state.folding_ranges.is_empty() {
529            // --- LSP-provided ranges (line-based) ---
530            // LSP ranges use line numbers, so we need get_line_number here.
531            // Resolve marker-backed ranges to current post-edit line numbers.
532            let resolved = state
533                .folding_ranges
534                .resolved(&state.buffer, &state.marker_list);
535            let line = state.buffer.get_line_number(byte_pos);
536            let mut exact_range: Option<&lsp_types::FoldingRange> = None;
537            let mut exact_span = usize::MAX;
538            let mut containing_range: Option<&lsp_types::FoldingRange> = None;
539            let mut containing_span = usize::MAX;
540
541            for range in &resolved {
542                let start_line = range.start_line as usize;
543                let range_end = range.end_line as usize;
544                if range_end <= start_line {
545                    continue;
546                }
547                let span = range_end.saturating_sub(start_line);
548
549                if start_line == line && span < exact_span {
550                    exact_span = span;
551                    exact_range = Some(range);
552                }
553                if start_line <= line && line <= range_end && span < containing_span {
554                    containing_span = span;
555                    containing_range = Some(range);
556                }
557            }
558
559            let chosen = exact_range.or(containing_range);
560            let Some(range) = chosen else {
561                return;
562            };
563            let placeholder = range
564                .collapsed_text
565                .as_ref()
566                .filter(|text| !text.trim().is_empty())
567                .cloned();
568            let header_line = range.start_line as usize;
569            let end_line = range.end_line as usize;
570            let first_hidden = header_line.saturating_add(1);
571            if first_hidden > end_line {
572                return;
573            }
574            let Some(sb) = state.buffer.line_start_offset(first_hidden) else {
575                return;
576            };
577            let eb = state
578                .buffer
579                .line_start_offset(end_line.saturating_add(1))
580                .unwrap_or_else(|| state.buffer.len());
581            let hb = state.buffer.line_start_offset(header_line).unwrap_or(0);
582            Self::create_fold(state, buf_state, sb, eb, hb, placeholder);
583        } else {
584            // --- Indent-based folding on bytes ---
585            use crate::view::folding::indent_folding;
586            let tab_size = state.buffer_settings.tab_size;
587            let max_upward = crate::config::INDENT_FOLD_MAX_UPWARD_SCAN;
588            let est_ll = state.buffer.estimated_line_length();
589            let max_scan_bytes = crate::config::INDENT_FOLD_MAX_SCAN_LINES * est_ll;
590
591            // Ensure the region around the cursor is loaded from disk so the
592            // immutable slice_bytes in find_fold_range_at_byte can read it.
593            let upward_bytes = max_upward * est_ll;
594            let load_start = byte_pos.saturating_sub(upward_bytes);
595            let load_end = byte_pos
596                .saturating_add(max_scan_bytes)
597                .min(state.buffer.len());
598            // Load chunks from disk so immutable slice_bytes in
599            // find_fold_range_at_byte can read the region.
600            drop(
601                state
602                    .buffer
603                    .get_text_range_mut(load_start, load_end - load_start),
604            );
605
606            if let Some((hb, sb, eb)) = indent_folding::find_fold_range_at_byte(
607                &state.buffer,
608                byte_pos,
609                tab_size,
610                max_scan_bytes,
611                max_upward,
612            ) {
613                Self::create_fold(state, buf_state, sb, eb, hb, None);
614            }
615        }
616    }
617
618    fn create_fold(
619        state: &mut crate::state::EditorState,
620        buf_state: &mut crate::view::split::BufferViewState,
621        start_byte: usize,
622        end_byte: usize,
623        header_byte: usize,
624        placeholder: Option<String>,
625    ) {
626        if end_byte <= start_byte {
627            return;
628        }
629
630        // Move any cursors inside the soon-to-be-hidden range to the header line.
631        buf_state.cursors.map(|cursor| {
632            let in_hidden_range = cursor.position >= start_byte && cursor.position < end_byte;
633            let anchor_in_hidden = cursor
634                .anchor
635                .is_some_and(|anchor| anchor >= start_byte && anchor < end_byte);
636            if in_hidden_range || anchor_in_hidden {
637                cursor.position = header_byte;
638                cursor.anchor = None;
639                cursor.sticky_column = 0;
640                cursor.selection_mode = crate::model::cursor::SelectionMode::Normal;
641                cursor.block_anchor = None;
642                cursor.deselect_on_move = true;
643            }
644        });
645
646        buf_state
647            .folds
648            .add(&mut state.marker_list, start_byte, end_byte, placeholder);
649
650        // If the viewport top is now inside the folded range, move it to the header.
651        if buf_state.viewport.top_byte >= start_byte && buf_state.viewport.top_byte < end_byte {
652            buf_state.viewport.top_byte = header_byte;
653            buf_state.viewport.top_view_line_offset = 0;
654        }
655    }
656
657    /// Send didClose to a specific named server for all buffers of a language.
658    ///
659    /// Used when stopping a single server out of multiple for the same language,
660    /// where we don't want to fully disable LSP for the buffers.
661    pub(crate) fn send_did_close_to_server(&mut self, language: &str, server_name: &str) {
662        let uris: Vec<_> = self
663            .buffers
664            .iter()
665            .filter(|(_, s)| s.language == language)
666            .filter_map(|(id, _)| {
667                self.buffer_metadata
668                    .get(id)
669                    .and_then(|m| m.file_uri())
670                    .cloned()
671            })
672            .collect();
673
674        if let Some(lsp) = self.lsp.as_mut() {
675            for sh in lsp.get_handles_mut(language) {
676                if sh.name == server_name {
677                    for uri in &uris {
678                        tracing::info!(
679                            "Sending didClose for {} to '{}' (language: {})",
680                            uri.as_str(),
681                            sh.name,
682                            language
683                        );
684                        if let Err(e) = sh.handle.did_close(uri.clone()) {
685                            tracing::warn!("Failed to send didClose to '{}': {}", sh.name, e);
686                        }
687                    }
688                    break;
689                }
690            }
691        }
692    }
693
694    /// Core server-stop teardown shared by the command-palette and
695    /// status-popup stop paths.
696    ///
697    /// Does the three things that must travel together, in the right
698    /// order:
699    ///
700    /// 1. Shutdown the manager handle(s) — either a single named server
701    ///    or every server configured for `language` (`server_name = None`).
702    /// 2. Clear the matching `lsp_server_statuses` entries on the editor
703    ///    so the status-bar indicator (`compose_lsp_status` in
704    ///    `app/render.rs`) doesn't stay stuck at `"LSP (on)"` with a
705    ///    stale `Running` entry. This is the step the palette path
706    ///    used to miss, producing the user-reported stale-indicator
707    ///    bug.
708    /// 3. Drop diagnostics published by the stopped server(s) so
709    ///    red/yellow overlays don't persist on-screen after the
710    ///    producer is gone.
711    ///
712    /// `didClose` for open buffers is the caller's responsibility and
713    /// MUST happen before this function: the handles are removed as
714    /// part of step 1. The palette caller layers config updates
715    /// (`auto_start = false`) and a user-facing status message on top.
716    ///
717    /// Returns `true` if anything was actually stopped (matches
718    /// `LspManager::shutdown_server`'s contract).
719    pub(crate) fn stop_lsp_server_and_cleanup(
720        &mut self,
721        language: &str,
722        server_name: Option<&str>,
723    ) -> bool {
724        // Snapshot the server names we're about to drop — once the
725        // handles are gone the manager can't enumerate them anymore,
726        // and we need the names for the status + diagnostic cleanup.
727        let stopping_names: Vec<String> = if let Some(name) = server_name {
728            vec![name.to_string()]
729        } else {
730            self.lsp
731                .as_ref()
732                .map(|lsp| lsp.server_names_for_language(language))
733                .unwrap_or_default()
734        };
735
736        let stopped = if let Some(lsp) = self.lsp.as_mut() {
737            if let Some(name) = server_name {
738                lsp.shutdown_server_by_name(language, name)
739            } else {
740                lsp.shutdown_server(language)
741            }
742        } else {
743            false
744        };
745
746        if !stopped {
747            return false;
748        }
749
750        for name in &stopping_names {
751            self.lsp_server_statuses
752                .remove(&(language.to_string(), name.clone()));
753            // Clear diagnostics this server published so overlays clear
754            // from every buffer it touched (not just the active one).
755            self.clear_diagnostics_for_server(name);
756        }
757
758        true
759    }
760
761    /// Disable LSP for a specific buffer and clear all LSP-related data
762    pub(crate) fn disable_lsp_for_buffer(&mut self, buffer_id: crate::model::event::BufferId) {
763        // Send didClose to the LSP server so it removes the document from its
764        // tracking. This is critical: without didClose, the async handler's
765        // document_versions still has the path, and should_skip_did_open will
766        // block the didOpen when LSP is re-enabled — causing a desync where
767        // the server has stale content. (GitHub issue #952)
768        if let Some(uri) = self
769            .buffer_metadata
770            .get(&buffer_id)
771            .and_then(|m| m.file_uri())
772            .cloned()
773        {
774            let language = self
775                .buffers
776                .get(&buffer_id)
777                .map(|s| s.language.clone())
778                .unwrap_or_default();
779            if let Some(lsp) = self.lsp.as_mut() {
780                // Broadcast didClose to all handles for this language
781                if !lsp.has_handles(&language) {
782                    tracing::warn!(
783                        "disable_lsp_for_buffer: no handle for language '{}'",
784                        language
785                    );
786                } else {
787                    for sh in lsp.get_handles_mut(&language) {
788                        tracing::info!(
789                            "Sending didClose for {} to '{}' (language: {})",
790                            uri.as_str(),
791                            sh.name,
792                            language
793                        );
794                        if let Err(e) = sh.handle.did_close(uri.clone()) {
795                            tracing::warn!("Failed to send didClose to '{}': {}", sh.name, e);
796                        }
797                    }
798                }
799            } else {
800                tracing::warn!("disable_lsp_for_buffer: no LSP manager");
801            }
802        } else {
803            tracing::warn!("disable_lsp_for_buffer: no URI for buffer");
804        }
805
806        // Disable LSP in metadata
807        if let Some(metadata) = self.buffer_metadata.get_mut(&buffer_id) {
808            metadata.disable_lsp(t!("lsp.disabled.user").to_string());
809            // Clear LSP opened tracking so it will be sent again if re-enabled
810            metadata.lsp_opened_with.clear();
811        }
812        self.set_status_message(t!("lsp.disabled_for_buffer").to_string());
813
814        // Clear diagnostics for this buffer
815        let uri = self
816            .buffer_metadata
817            .get(&buffer_id)
818            .and_then(|m| m.file_uri())
819            .map(|u| u.as_str().to_string());
820
821        if let Some(uri_str) = uri {
822            self.stored_diagnostics_mut().remove(&uri_str);
823            self.stored_push_diagnostics.remove(&uri_str);
824            self.stored_pull_diagnostics.remove(&uri_str);
825            self.diagnostic_result_ids.remove(&uri_str);
826            self.stored_folding_ranges_mut().remove(&uri_str);
827        }
828
829        // Cancel scheduled diagnostic pull if it targets this buffer
830        if let Some((scheduled_buf, _)) = &self.scheduled_diagnostic_pull {
831            if *scheduled_buf == buffer_id {
832                self.scheduled_diagnostic_pull = None;
833            }
834        }
835
836        // Cancel scheduled inlay hints refresh if it targets this buffer
837        if let Some((scheduled_buf, _)) = &self.scheduled_inlay_hints_request {
838            if *scheduled_buf == buffer_id {
839                self.scheduled_inlay_hints_request = None;
840            }
841        }
842
843        self.folding_ranges_in_flight.remove(&buffer_id);
844        self.folding_ranges_debounce.remove(&buffer_id);
845        self.pending_folding_range_requests
846            .retain(|_, req| req.buffer_id != buffer_id);
847        // Drop any in-flight inlay hint requests for this buffer so
848        // their eventual responses don't repopulate the cleared overlay.
849        self.pending_inlay_hints_requests
850            .retain(|_, req| req.buffer_id != buffer_id);
851
852        // Clear all LSP-related overlays for this buffer (diagnostics + inlay hints)
853        let diagnostic_ns = crate::services::lsp::diagnostics::lsp_diagnostic_namespace();
854        let (buffers, split_view_states) = (&mut self.buffers, &mut self.split_view_states);
855        if let Some(state) = buffers.get_mut(&buffer_id) {
856            state
857                .overlays
858                .clear_namespace(&diagnostic_ns, &mut state.marker_list);
859            state.virtual_texts.clear(&mut state.marker_list);
860            state.folding_ranges.clear(&mut state.marker_list);
861            for view_state in split_view_states.values_mut() {
862                if let Some(buf_state) = view_state.keyed_states.get_mut(&buffer_id) {
863                    buf_state.folds.clear(&mut state.marker_list);
864                }
865            }
866        }
867    }
868
869    /// Enable LSP for a specific buffer and send didOpen notification
870    fn enable_lsp_for_buffer(
871        &mut self,
872        buffer_id: crate::model::event::BufferId,
873        language: &str,
874        file_path: Option<std::path::PathBuf>,
875    ) {
876        // Re-enable LSP in metadata
877        if let Some(metadata) = self.buffer_metadata.get_mut(&buffer_id) {
878            metadata.lsp_enabled = true;
879            metadata.lsp_disabled_reason = None;
880        }
881        self.set_status_message(t!("lsp.enabled_for_buffer").to_string());
882
883        // Send didOpen if we have a file path
884        if let Some(_path) = file_path {
885            self.send_lsp_did_open_for_buffer(buffer_id, language);
886        }
887    }
888
889    /// Send LSP didOpen notification for a buffer
890    fn send_lsp_did_open_for_buffer(
891        &mut self,
892        buffer_id: crate::model::event::BufferId,
893        language: &str,
894    ) {
895        // Get the URI and buffer text
896        let (uri, text) = {
897            let metadata = self.buffer_metadata.get(&buffer_id);
898            let uri = metadata.and_then(|m| m.file_uri()).cloned();
899            let text = self
900                .buffers
901                .get(&buffer_id)
902                .and_then(|state| state.buffer.to_string());
903            (uri, text)
904        };
905
906        let Some(uri) = uri else { return };
907        let Some(text) = text else { return };
908
909        // Try to spawn and send didOpen
910        use crate::services::lsp::manager::LspSpawnResult;
911        let file_path = self
912            .buffer_metadata
913            .get(&buffer_id)
914            .and_then(|m| m.file_path())
915            .cloned();
916        let Some(lsp) = self.lsp.as_mut() else {
917            return;
918        };
919
920        if lsp.try_spawn(language, file_path.as_deref()) != LspSpawnResult::Spawned {
921            return;
922        }
923
924        let Some(handle) = lsp.get_handle_mut(language) else {
925            return;
926        };
927
928        let handle_id = handle.id();
929        if let Err(e) = handle.did_open(uri.clone(), text, language.to_string()) {
930            tracing::warn!("Failed to send didOpen to LSP: {}", e);
931            return;
932        }
933
934        // Mark buffer as opened with this server
935        if let Some(metadata) = self.buffer_metadata.get_mut(&buffer_id) {
936            metadata.lsp_opened_with.insert(handle_id);
937        }
938
939        // Request diagnostics
940        let request_id = self.next_lsp_request_id;
941        self.next_lsp_request_id += 1;
942        let previous_result_id = self.diagnostic_result_ids.get(uri.as_str()).cloned();
943        if let Err(e) = handle.document_diagnostic(request_id, uri.clone(), previous_result_id) {
944            tracing::warn!("LSP document_diagnostic request failed: {}", e);
945        }
946
947        // Request inlay hints if enabled
948        if self.config.editor.enable_inlay_hints {
949            let (last_line, last_char, buffer_version) = self
950                .buffers
951                .get(&buffer_id)
952                .map(|state| {
953                    let line_count = state.buffer.line_count().unwrap_or(1000);
954                    (
955                        line_count.saturating_sub(1) as u32,
956                        10000u32,
957                        state.buffer.version(),
958                    )
959                })
960                .unwrap_or((999, 10000, 0));
961
962            let request_id = self.next_lsp_request_id;
963            self.next_lsp_request_id += 1;
964            if let Err(e) = handle.inlay_hints(request_id, uri, 0, 0, last_line, last_char) {
965                tracing::warn!("LSP inlay_hints request failed: {}", e);
966            } else {
967                self.pending_inlay_hints_requests.insert(
968                    request_id,
969                    super::InlayHintsRequest {
970                        buffer_id,
971                        version: buffer_version,
972                    },
973                );
974            }
975        }
976
977        // Schedule folding range refresh
978        self.schedule_folding_ranges_refresh(buffer_id);
979    }
980
981    /// Set up a plugin development workspace for LSP support on a buffer.
982    ///
983    /// Creates a temp directory with `fresh.d.ts` + `tsconfig.json` so that
984    /// `typescript-language-server` can provide autocomplete and type checking
985    /// for plugin buffers (including unsaved/unnamed ones).
986    pub(crate) fn setup_plugin_dev_lsp(&mut self, buffer_id: BufferId, content: &str) {
987        use crate::services::plugins::plugin_dev_workspace::PluginDevWorkspace;
988
989        // Use the exact cached extraction location for fresh.d.ts
990        #[cfg(feature = "embed-plugins")]
991        let fresh_dts_path = {
992            let Some(embedded_dir) = crate::services::plugins::embedded::get_embedded_plugins_dir()
993            else {
994                tracing::warn!(
995                    "Cannot set up plugin dev LSP: embedded plugins directory not available"
996                );
997                return;
998            };
999            let path = embedded_dir.join("lib").join("fresh.d.ts");
1000            if !path.exists() {
1001                tracing::warn!(
1002                    "Cannot set up plugin dev LSP: fresh.d.ts not found at {:?}",
1003                    path
1004                );
1005                return;
1006            }
1007            path
1008        };
1009
1010        #[cfg(not(feature = "embed-plugins"))]
1011        let fresh_dts_path = {
1012            // In non-embedded builds (development), use the source tree path
1013            let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1014                .join("plugins")
1015                .join("lib")
1016                .join("fresh.d.ts");
1017            if !path.exists() {
1018                tracing::warn!(
1019                    "Cannot set up plugin dev LSP: fresh.d.ts not found at {:?}",
1020                    path
1021                );
1022                return;
1023            }
1024            path
1025        };
1026
1027        // Create the workspace
1028        let buffer_id_num: usize = buffer_id.0;
1029        match PluginDevWorkspace::create(buffer_id_num, content, &fresh_dts_path) {
1030            Ok(workspace) => {
1031                let plugin_file = workspace.plugin_file.clone();
1032
1033                // Update buffer metadata to point at the temp file, enabling LSP
1034                if let Some(metadata) = self.buffer_metadata.get_mut(&buffer_id) {
1035                    if let Some(uri) = super::types::file_path_to_lsp_uri(&plugin_file) {
1036                        metadata.kind = super::types::BufferKind::File {
1037                            path: plugin_file.clone(),
1038                            uri: Some(uri),
1039                        };
1040                        metadata.lsp_enabled = true;
1041                        metadata.lsp_disabled_reason = None;
1042                        // Clear any previous LSP opened state so didOpen is sent fresh
1043                        metadata.lsp_opened_with.clear();
1044
1045                        tracing::info!(
1046                            "Plugin dev LSP enabled for buffer {} via {:?}",
1047                            buffer_id_num,
1048                            plugin_file
1049                        );
1050                    }
1051                }
1052
1053                // Set buffer language to TypeScript so LSP requests use the right handle
1054                if let Some(state) = self.buffers.get_mut(&buffer_id) {
1055                    let first_line = state.buffer.first_line_lossy();
1056                    let detected =
1057                        crate::primitives::detected_language::DetectedLanguage::from_path(
1058                            &plugin_file,
1059                            first_line.as_deref(),
1060                            &self.grammar_registry,
1061                            &self.config.languages,
1062                        );
1063                    state.apply_language(detected);
1064                }
1065
1066                // Allow TypeScript language so LSP auto-spawns
1067                if let Some(lsp) = &mut self.lsp {
1068                    lsp.allow_language("typescript");
1069                }
1070
1071                // Store workspace for cleanup
1072                let workspace_dir = workspace.dir().to_path_buf();
1073                self.plugin_dev_workspaces.insert(buffer_id, workspace);
1074
1075                // Actually spawn the LSP server and send didOpen for this buffer
1076                self.send_lsp_did_open_for_buffer(buffer_id, "typescript");
1077
1078                // Add the plugin workspace folder so tsserver discovers tsconfig.json + fresh.d.ts
1079                if let Some(lsp) = &self.lsp {
1080                    if let Some(handle) = lsp.get_handle("typescript") {
1081                        if let Some(uri) = super::types::file_path_to_lsp_uri(&workspace_dir) {
1082                            let name = workspace_dir
1083                                .file_name()
1084                                .unwrap_or_default()
1085                                .to_string_lossy()
1086                                .into_owned();
1087                            if let Err(e) = handle.add_workspace_folder(uri, name) {
1088                                tracing::warn!("Failed to add plugin workspace folder: {}", e);
1089                            } else {
1090                                tracing::info!(
1091                                    "Added plugin workspace folder: {:?}",
1092                                    workspace_dir
1093                                );
1094                            }
1095                        }
1096                    }
1097                }
1098            }
1099            Err(e) => {
1100                tracing::warn!("Failed to create plugin dev workspace: {}", e);
1101            }
1102        }
1103    }
1104}