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        for (buffer_id, buf_path) in buffers_for_language {
134            let Some(state) = self.buffers.get(&buffer_id) else {
135                continue;
136            };
137
138            let Some(content) = state.buffer.to_string() else {
139                continue; // Skip buffers that aren't fully loaded
140            };
141
142            let Some(uri) = super::types::file_path_to_lsp_uri(&buf_path) else {
143                continue;
144            };
145
146            let lang_id = state.language.clone();
147
148            if let Some(lsp) = self.lsp.as_mut() {
149                // Respect auto_start setting for this user action
150                use crate::services::lsp::manager::LspSpawnResult;
151                if lsp.try_spawn(&lang_id, Some(&buf_path)) != LspSpawnResult::Spawned {
152                    continue;
153                }
154
155                // Collect handles that need didOpen (not yet tracked in
156                // lsp_opened_with for this buffer).
157                let opened_with = self
158                    .buffer_metadata
159                    .get(&buffer_id)
160                    .map(|m| m.lsp_opened_with.clone())
161                    .unwrap_or_default();
162
163                let handles_needing_open: Vec<(String, u64)> = lsp
164                    .get_handles(&lang_id)
165                    .into_iter()
166                    .filter(|sh| !opened_with.contains(&sh.handle.id()))
167                    .map(|sh| (sh.name.clone(), sh.handle.id()))
168                    .collect();
169
170                // Send didOpen to each handle that hasn't seen this buffer yet
171                for (name, handle_id) in handles_needing_open {
172                    let sh = lsp
173                        .get_handles_mut(&lang_id)
174                        .into_iter()
175                        .find(|s| s.handle.id() == handle_id);
176
177                    if let Some(sh) = sh {
178                        if let Err(e) =
179                            sh.handle
180                                .did_open(uri.clone(), content.clone(), lang_id.clone())
181                        {
182                            tracing::warn!("LSP did_open to '{}' failed: {}", name, e);
183                        } else if let Some(metadata) = self.buffer_metadata.get_mut(&buffer_id) {
184                            metadata.lsp_opened_with.insert(handle_id);
185                        }
186                    }
187                }
188            }
189        }
190    }
191
192    /// Handle the LspStop action.
193    ///
194    /// Shows a prompt to select which LSP server to stop, with suggestions
195    /// for all currently running servers.
196    pub fn handle_lsp_stop(&mut self) {
197        let running_languages: Vec<String> = self
198            .lsp
199            .as_ref()
200            .map(|lsp| lsp.running_servers())
201            .unwrap_or_default();
202
203        if running_languages.is_empty() {
204            self.set_status_message(t!("lsp.no_servers_running").to_string());
205            return;
206        }
207
208        // Build suggestions showing server names when multiple servers per language
209        let mut suggestions: Vec<Suggestion> = Vec::new();
210        for lang in &running_languages {
211            let server_names: Vec<String> = self
212                .lsp
213                .as_ref()
214                .map(|lsp| lsp.server_names_for_language(lang))
215                .unwrap_or_default();
216
217            if server_names.len() > 1 {
218                // Multiple servers: show each individually
219                for name in &server_names {
220                    let description = Some(format!("Server: {}", name));
221                    suggestions.push(Suggestion {
222                        text: format!("{}/{}", lang, name),
223                        description,
224                        // Value carries "language/server_name" so the handler
225                        // knows exactly which server to stop.
226                        value: Some(format!("{}/{}", lang, name)),
227                        disabled: false,
228                        keybinding: None,
229                        source: None,
230                    });
231                }
232            } else {
233                // Single server: show language only (value = just language)
234                let description = self
235                    .lsp
236                    .as_ref()
237                    .and_then(|lsp| lsp.get_config(lang))
238                    .filter(|c| !c.command.is_empty())
239                    .map(|c| format!("Command: {}", c.command));
240
241                suggestions.push(Suggestion {
242                    text: lang.clone(),
243                    description,
244                    value: Some(lang.clone()),
245                    disabled: false,
246                    keybinding: None,
247                    source: None,
248                });
249            }
250        }
251
252        // Start prompt with suggestions
253        self.prompt = Some(Prompt::with_suggestions(
254            "Stop LSP server: ".to_string(),
255            PromptType::StopLspServer,
256            suggestions.clone(),
257        ));
258
259        // Configure initial selection
260        if let Some(prompt) = self.prompt.as_mut() {
261            if suggestions.len() == 1 {
262                // If only one entry, pre-fill the input with it
263                prompt.input = suggestions[0].text.clone();
264                prompt.cursor_pos = prompt.input.len();
265                prompt.selected_suggestion = Some(0);
266            } else if !prompt.suggestions.is_empty() {
267                // Auto-select first suggestion
268                prompt.selected_suggestion = Some(0);
269            }
270        }
271    }
272
273    /// Handle the LspToggleForBuffer action.
274    ///
275    /// Toggles LSP on/off for the current buffer only.
276    /// Requires an LSP server to be configured for the current buffer's language.
277    pub fn handle_lsp_toggle_for_buffer(&mut self) {
278        let buffer_id = self.active_buffer();
279
280        // Get the buffer's language to check if LSP is configured
281        let language = {
282            let Some(state) = self.buffers.get(&buffer_id) else {
283                return;
284            };
285            state.language.clone()
286        };
287
288        // Check if LSP is configured for this language
289        let lsp_configured = self
290            .lsp
291            .as_ref()
292            .and_then(|lsp| lsp.get_config(&language))
293            .is_some();
294
295        if !lsp_configured {
296            self.set_status_message(t!("lsp.no_server_configured").to_string());
297            return;
298        }
299
300        // Check current LSP state
301        let (was_enabled, file_path) = {
302            let Some(metadata) = self.buffer_metadata.get(&buffer_id) else {
303                return;
304            };
305            (metadata.lsp_enabled, metadata.file_path().cloned())
306        };
307
308        if was_enabled {
309            self.disable_lsp_for_buffer(buffer_id);
310        } else {
311            self.enable_lsp_for_buffer(buffer_id, &language, file_path);
312        }
313    }
314
315    /// Handle an action from the LSP status details popup.
316    ///
317    /// Action keys have the format:
318    /// - `restart:<language>/<server_name>` — restart a specific server
319    /// - `start:<language>` — start LSP server(s) for a language
320    /// - `stop:<language>/<server_name>` — stop a specific server
321    /// - `log:<language>` — open the LSP log file for the language
322    pub fn handle_lsp_status_action(&mut self, action_key: &str) {
323        if let Some(language) = action_key.strip_prefix("start:") {
324            // Start/restart LSP for this language (same as the "Start/Restart LSP" command)
325            let file_path = self
326                .buffer_metadata
327                .get(&self.active_buffer())
328                .and_then(|meta| meta.file_path().cloned());
329
330            if let Some(lsp) = self.lsp.as_mut() {
331                let (_, message) = lsp.manual_restart(language, file_path.as_deref());
332                self.status_message = Some(message);
333            } else {
334                self.status_message = Some("No LSP manager available".to_string());
335            }
336            self.reopen_buffers_for_language(language);
337        } else if let Some(target) = action_key.strip_prefix("restart:") {
338            // Parse language/server_name
339            if let Some((language, server_name)) = target.split_once('/') {
340                let file_path = self
341                    .buffer_metadata
342                    .get(&self.active_buffer())
343                    .and_then(|meta| meta.file_path().cloned());
344
345                if let Some(lsp) = self.lsp.as_mut() {
346                    // Shutdown the specific server first, then re-spawn
347                    lsp.shutdown_server_by_name(language, server_name);
348                }
349                // Remove the status entry so it gets re-created on spawn
350                self.lsp_server_statuses
351                    .remove(&(language.to_string(), server_name.to_string()));
352                self.update_lsp_status_from_server_statuses();
353
354                if let Some(lsp) = self.lsp.as_mut() {
355                    let _ = lsp.manual_restart(language, file_path.as_deref());
356                }
357                self.reopen_buffers_for_language(language);
358                self.status_message = Some(format!(
359                    "Restarting LSP server: {}/{}",
360                    language, server_name
361                ));
362            }
363        } else if let Some(target) = action_key.strip_prefix("stop:") {
364            if let Some((language, server_name)) = target.split_once('/') {
365                let stopped = if let Some(lsp) = self.lsp.as_mut() {
366                    lsp.shutdown_server_by_name(language, server_name)
367                } else {
368                    false
369                };
370                if stopped {
371                    self.lsp_server_statuses
372                        .remove(&(language.to_string(), server_name.to_string()));
373                    self.update_lsp_status_from_server_statuses();
374                    self.status_message =
375                        Some(format!("Stopped LSP server: {}/{}", language, server_name));
376                } else {
377                    self.status_message = Some(format!(
378                        "LSP server not running: {}/{}",
379                        language, server_name
380                    ));
381                }
382            }
383        } else if let Some(language) = action_key.strip_prefix("log:") {
384            let log_path = crate::services::log_dirs::lsp_log_path(language);
385            if log_path.exists() {
386                match self.open_local_file(&log_path) {
387                    Ok(buffer_id) => {
388                        self.mark_buffer_read_only(buffer_id, true);
389                    }
390                    Err(e) => {
391                        self.status_message = Some(format!("Failed to open LSP log: {}", e));
392                    }
393                }
394            } else {
395                self.status_message = Some(format!("No log file found for {}", language));
396            }
397        }
398    }
399
400    /// Toggle folding at the current cursor position.
401    pub fn toggle_fold_at_cursor(&mut self) {
402        let buffer_id = self.active_buffer();
403        let pos = self.active_cursors().primary().position;
404        self.toggle_fold_at_byte(buffer_id, pos);
405    }
406
407    /// Toggle folding for the given line in the specified buffer.
408    ///
409    /// Kept for callers that only have a line number (e.g. gutter clicks
410    /// that already resolved the line).  Converts to a byte position and
411    /// delegates to [`Self::toggle_fold_at_byte`].
412    pub fn toggle_fold_at_line(&mut self, buffer_id: BufferId, line: usize) {
413        let byte_pos = {
414            let Some(state) = self.buffers.get(&buffer_id) else {
415                return;
416            };
417            state.buffer.line_start_offset(line).unwrap_or_else(|| {
418                use crate::view::folding::indent_folding;
419                let approx = line * state.buffer.estimated_line_length();
420                indent_folding::find_line_start_byte(&state.buffer, approx)
421            })
422        };
423        self.toggle_fold_at_byte(buffer_id, byte_pos);
424    }
425
426    /// Toggle folding at the given byte position in the specified buffer.
427    pub fn toggle_fold_at_byte(&mut self, buffer_id: BufferId, byte_pos: usize) {
428        let split_id = self.split_manager.active_split();
429        let (buffers, split_view_states) = (&mut self.buffers, &mut self.split_view_states);
430
431        let Some(state) = buffers.get_mut(&buffer_id) else {
432            return;
433        };
434
435        let Some(view_state) = split_view_states.get_mut(&split_id) else {
436            return;
437        };
438        let buf_state = view_state.ensure_buffer_state(buffer_id);
439
440        // Try to unfold first — check if this byte's line is a fold header.
441        let header_byte = {
442            use crate::view::folding::indent_folding;
443            indent_folding::find_line_start_byte(&state.buffer, byte_pos)
444        };
445        if buf_state
446            .folds
447            .remove_by_header_byte(&state.buffer, &mut state.marker_list, header_byte)
448        {
449            return;
450        }
451
452        // Also unfold if the byte position is inside an existing fold.
453        if buf_state
454            .folds
455            .remove_if_contains_byte(&mut state.marker_list, byte_pos)
456        {
457            return;
458        }
459
460        // Determine the fold byte range: prefer LSP ranges, fall back to indent-based.
461        if !state.folding_ranges.is_empty() {
462            // --- LSP-provided ranges (line-based) ---
463            // LSP ranges use line numbers, so we need get_line_number here.
464            let line = state.buffer.get_line_number(byte_pos);
465            let mut exact_range: Option<&lsp_types::FoldingRange> = None;
466            let mut exact_span = usize::MAX;
467            let mut containing_range: Option<&lsp_types::FoldingRange> = None;
468            let mut containing_span = usize::MAX;
469
470            for range in &state.folding_ranges {
471                let start_line = range.start_line as usize;
472                let range_end = range.end_line as usize;
473                if range_end <= start_line {
474                    continue;
475                }
476                let span = range_end.saturating_sub(start_line);
477
478                if start_line == line && span < exact_span {
479                    exact_span = span;
480                    exact_range = Some(range);
481                }
482                if start_line <= line && line <= range_end && span < containing_span {
483                    containing_span = span;
484                    containing_range = Some(range);
485                }
486            }
487
488            let chosen = exact_range.or(containing_range);
489            let Some(range) = chosen else {
490                return;
491            };
492            let placeholder = range
493                .collapsed_text
494                .as_ref()
495                .filter(|text| !text.trim().is_empty())
496                .cloned();
497            let header_line = range.start_line as usize;
498            let end_line = range.end_line as usize;
499            let first_hidden = header_line.saturating_add(1);
500            if first_hidden > end_line {
501                return;
502            }
503            let Some(sb) = state.buffer.line_start_offset(first_hidden) else {
504                return;
505            };
506            let eb = state
507                .buffer
508                .line_start_offset(end_line.saturating_add(1))
509                .unwrap_or_else(|| state.buffer.len());
510            let hb = state.buffer.line_start_offset(header_line).unwrap_or(0);
511            Self::create_fold(state, buf_state, sb, eb, hb, placeholder);
512        } else {
513            // --- Indent-based folding on bytes ---
514            use crate::view::folding::indent_folding;
515            let tab_size = state.buffer_settings.tab_size;
516            let max_upward = crate::config::INDENT_FOLD_MAX_UPWARD_SCAN;
517            let est_ll = state.buffer.estimated_line_length();
518            let max_scan_bytes = crate::config::INDENT_FOLD_MAX_SCAN_LINES * est_ll;
519
520            // Ensure the region around the cursor is loaded from disk so the
521            // immutable slice_bytes in find_fold_range_at_byte can read it.
522            let upward_bytes = max_upward * est_ll;
523            let load_start = byte_pos.saturating_sub(upward_bytes);
524            let load_end = byte_pos
525                .saturating_add(max_scan_bytes)
526                .min(state.buffer.len());
527            // Load chunks from disk so immutable slice_bytes in
528            // find_fold_range_at_byte can read the region.
529            drop(
530                state
531                    .buffer
532                    .get_text_range_mut(load_start, load_end - load_start),
533            );
534
535            if let Some((hb, sb, eb)) = indent_folding::find_fold_range_at_byte(
536                &state.buffer,
537                byte_pos,
538                tab_size,
539                max_scan_bytes,
540                max_upward,
541            ) {
542                Self::create_fold(state, buf_state, sb, eb, hb, None);
543            }
544        }
545    }
546
547    fn create_fold(
548        state: &mut crate::state::EditorState,
549        buf_state: &mut crate::view::split::BufferViewState,
550        start_byte: usize,
551        end_byte: usize,
552        header_byte: usize,
553        placeholder: Option<String>,
554    ) {
555        if end_byte <= start_byte {
556            return;
557        }
558
559        // Move any cursors inside the soon-to-be-hidden range to the header line.
560        buf_state.cursors.map(|cursor| {
561            let in_hidden_range = cursor.position >= start_byte && cursor.position < end_byte;
562            let anchor_in_hidden = cursor
563                .anchor
564                .is_some_and(|anchor| anchor >= start_byte && anchor < end_byte);
565            if in_hidden_range || anchor_in_hidden {
566                cursor.position = header_byte;
567                cursor.anchor = None;
568                cursor.sticky_column = 0;
569                cursor.selection_mode = crate::model::cursor::SelectionMode::Normal;
570                cursor.block_anchor = None;
571                cursor.deselect_on_move = true;
572            }
573        });
574
575        buf_state
576            .folds
577            .add(&mut state.marker_list, start_byte, end_byte, placeholder);
578
579        // If the viewport top is now inside the folded range, move it to the header.
580        if buf_state.viewport.top_byte >= start_byte && buf_state.viewport.top_byte < end_byte {
581            buf_state.viewport.top_byte = header_byte;
582            buf_state.viewport.top_view_line_offset = 0;
583        }
584    }
585
586    /// Send didClose to a specific named server for all buffers of a language.
587    ///
588    /// Used when stopping a single server out of multiple for the same language,
589    /// where we don't want to fully disable LSP for the buffers.
590    pub(crate) fn send_did_close_to_server(&mut self, language: &str, server_name: &str) {
591        let uris: Vec<_> = self
592            .buffers
593            .iter()
594            .filter(|(_, s)| s.language == language)
595            .filter_map(|(id, _)| {
596                self.buffer_metadata
597                    .get(id)
598                    .and_then(|m| m.file_uri())
599                    .cloned()
600            })
601            .collect();
602
603        if let Some(lsp) = self.lsp.as_mut() {
604            for sh in lsp.get_handles_mut(language) {
605                if sh.name == server_name {
606                    for uri in &uris {
607                        tracing::info!(
608                            "Sending didClose for {} to '{}' (language: {})",
609                            uri.as_str(),
610                            sh.name,
611                            language
612                        );
613                        if let Err(e) = sh.handle.did_close(uri.clone()) {
614                            tracing::warn!("Failed to send didClose to '{}': {}", sh.name, e);
615                        }
616                    }
617                    break;
618                }
619            }
620        }
621    }
622
623    /// Disable LSP for a specific buffer and clear all LSP-related data
624    pub(crate) fn disable_lsp_for_buffer(&mut self, buffer_id: crate::model::event::BufferId) {
625        // Send didClose to the LSP server so it removes the document from its
626        // tracking. This is critical: without didClose, the async handler's
627        // document_versions still has the path, and should_skip_did_open will
628        // block the didOpen when LSP is re-enabled — causing a desync where
629        // the server has stale content. (GitHub issue #952)
630        if let Some(uri) = self
631            .buffer_metadata
632            .get(&buffer_id)
633            .and_then(|m| m.file_uri())
634            .cloned()
635        {
636            let language = self
637                .buffers
638                .get(&buffer_id)
639                .map(|s| s.language.clone())
640                .unwrap_or_default();
641            if let Some(lsp) = self.lsp.as_mut() {
642                // Broadcast didClose to all handles for this language
643                if !lsp.has_handles(&language) {
644                    tracing::warn!(
645                        "disable_lsp_for_buffer: no handle for language '{}'",
646                        language
647                    );
648                } else {
649                    for sh in lsp.get_handles_mut(&language) {
650                        tracing::info!(
651                            "Sending didClose for {} to '{}' (language: {})",
652                            uri.as_str(),
653                            sh.name,
654                            language
655                        );
656                        if let Err(e) = sh.handle.did_close(uri.clone()) {
657                            tracing::warn!("Failed to send didClose to '{}': {}", sh.name, e);
658                        }
659                    }
660                }
661            } else {
662                tracing::warn!("disable_lsp_for_buffer: no LSP manager");
663            }
664        } else {
665            tracing::warn!("disable_lsp_for_buffer: no URI for buffer");
666        }
667
668        // Disable LSP in metadata
669        if let Some(metadata) = self.buffer_metadata.get_mut(&buffer_id) {
670            metadata.disable_lsp(t!("lsp.disabled.user").to_string());
671            // Clear LSP opened tracking so it will be sent again if re-enabled
672            metadata.lsp_opened_with.clear();
673        }
674        self.set_status_message(t!("lsp.disabled_for_buffer").to_string());
675
676        // Clear diagnostics for this buffer
677        let uri = self
678            .buffer_metadata
679            .get(&buffer_id)
680            .and_then(|m| m.file_uri())
681            .map(|u| u.as_str().to_string());
682
683        if let Some(uri_str) = uri {
684            self.stored_diagnostics.remove(&uri_str);
685            self.stored_push_diagnostics.remove(&uri_str);
686            self.stored_pull_diagnostics.remove(&uri_str);
687            self.diagnostic_result_ids.remove(&uri_str);
688            self.stored_folding_ranges.remove(&uri_str);
689        }
690
691        // Cancel scheduled diagnostic pull if it targets this buffer
692        if let Some((scheduled_buf, _)) = &self.scheduled_diagnostic_pull {
693            if *scheduled_buf == buffer_id {
694                self.scheduled_diagnostic_pull = None;
695            }
696        }
697
698        self.folding_ranges_in_flight.remove(&buffer_id);
699        self.folding_ranges_debounce.remove(&buffer_id);
700        self.pending_folding_range_requests
701            .retain(|_, req| req.buffer_id != buffer_id);
702
703        // Clear all LSP-related overlays for this buffer (diagnostics + inlay hints)
704        let diagnostic_ns = crate::services::lsp::diagnostics::lsp_diagnostic_namespace();
705        let (buffers, split_view_states) = (&mut self.buffers, &mut self.split_view_states);
706        if let Some(state) = buffers.get_mut(&buffer_id) {
707            state
708                .overlays
709                .clear_namespace(&diagnostic_ns, &mut state.marker_list);
710            state.virtual_texts.clear(&mut state.marker_list);
711            state.folding_ranges.clear();
712            for view_state in split_view_states.values_mut() {
713                if let Some(buf_state) = view_state.keyed_states.get_mut(&buffer_id) {
714                    buf_state.folds.clear(&mut state.marker_list);
715                }
716            }
717        }
718    }
719
720    /// Enable LSP for a specific buffer and send didOpen notification
721    fn enable_lsp_for_buffer(
722        &mut self,
723        buffer_id: crate::model::event::BufferId,
724        language: &str,
725        file_path: Option<std::path::PathBuf>,
726    ) {
727        // Re-enable LSP in metadata
728        if let Some(metadata) = self.buffer_metadata.get_mut(&buffer_id) {
729            metadata.lsp_enabled = true;
730            metadata.lsp_disabled_reason = None;
731        }
732        self.set_status_message(t!("lsp.enabled_for_buffer").to_string());
733
734        // Send didOpen if we have a file path
735        if let Some(_path) = file_path {
736            self.send_lsp_did_open_for_buffer(buffer_id, language);
737        }
738    }
739
740    /// Send LSP didOpen notification for a buffer
741    fn send_lsp_did_open_for_buffer(
742        &mut self,
743        buffer_id: crate::model::event::BufferId,
744        language: &str,
745    ) {
746        // Get the URI and buffer text
747        let (uri, text) = {
748            let metadata = self.buffer_metadata.get(&buffer_id);
749            let uri = metadata.and_then(|m| m.file_uri()).cloned();
750            let text = self
751                .buffers
752                .get(&buffer_id)
753                .and_then(|state| state.buffer.to_string());
754            (uri, text)
755        };
756
757        let Some(uri) = uri else { return };
758        let Some(text) = text else { return };
759
760        // Try to spawn and send didOpen
761        use crate::services::lsp::manager::LspSpawnResult;
762        let file_path = self
763            .buffer_metadata
764            .get(&buffer_id)
765            .and_then(|m| m.file_path())
766            .cloned();
767        let Some(lsp) = self.lsp.as_mut() else {
768            return;
769        };
770
771        if lsp.try_spawn(language, file_path.as_deref()) != LspSpawnResult::Spawned {
772            return;
773        }
774
775        let Some(handle) = lsp.get_handle_mut(language) else {
776            return;
777        };
778
779        let handle_id = handle.id();
780        if let Err(e) = handle.did_open(uri.clone(), text, language.to_string()) {
781            tracing::warn!("Failed to send didOpen to LSP: {}", e);
782            return;
783        }
784
785        // Mark buffer as opened with this server
786        if let Some(metadata) = self.buffer_metadata.get_mut(&buffer_id) {
787            metadata.lsp_opened_with.insert(handle_id);
788        }
789
790        // Request diagnostics
791        let request_id = self.next_lsp_request_id;
792        self.next_lsp_request_id += 1;
793        let previous_result_id = self.diagnostic_result_ids.get(uri.as_str()).cloned();
794        if let Err(e) = handle.document_diagnostic(request_id, uri.clone(), previous_result_id) {
795            tracing::warn!("LSP document_diagnostic request failed: {}", e);
796        }
797
798        // Request inlay hints if enabled
799        if self.config.editor.enable_inlay_hints {
800            let (last_line, last_char) = self
801                .buffers
802                .get(&buffer_id)
803                .map(|state| {
804                    let line_count = state.buffer.line_count().unwrap_or(1000);
805                    (line_count.saturating_sub(1) as u32, 10000u32)
806                })
807                .unwrap_or((999, 10000));
808
809            let request_id = self.next_lsp_request_id;
810            self.next_lsp_request_id += 1;
811            if let Err(e) = handle.inlay_hints(request_id, uri, 0, 0, last_line, last_char) {
812                tracing::warn!("LSP inlay_hints request failed: {}", e);
813            }
814        }
815
816        // Schedule folding range refresh
817        self.schedule_folding_ranges_refresh(buffer_id);
818    }
819
820    /// Set up a plugin development workspace for LSP support on a buffer.
821    ///
822    /// Creates a temp directory with `fresh.d.ts` + `tsconfig.json` so that
823    /// `typescript-language-server` can provide autocomplete and type checking
824    /// for plugin buffers (including unsaved/unnamed ones).
825    pub(crate) fn setup_plugin_dev_lsp(&mut self, buffer_id: BufferId, content: &str) {
826        use crate::services::plugins::plugin_dev_workspace::PluginDevWorkspace;
827
828        // Use the exact cached extraction location for fresh.d.ts
829        #[cfg(feature = "embed-plugins")]
830        let fresh_dts_path = {
831            let Some(embedded_dir) = crate::services::plugins::embedded::get_embedded_plugins_dir()
832            else {
833                tracing::warn!(
834                    "Cannot set up plugin dev LSP: embedded plugins directory not available"
835                );
836                return;
837            };
838            let path = embedded_dir.join("lib").join("fresh.d.ts");
839            if !path.exists() {
840                tracing::warn!(
841                    "Cannot set up plugin dev LSP: fresh.d.ts not found at {:?}",
842                    path
843                );
844                return;
845            }
846            path
847        };
848
849        #[cfg(not(feature = "embed-plugins"))]
850        let fresh_dts_path = {
851            // In non-embedded builds (development), use the source tree path
852            let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
853                .join("plugins")
854                .join("lib")
855                .join("fresh.d.ts");
856            if !path.exists() {
857                tracing::warn!(
858                    "Cannot set up plugin dev LSP: fresh.d.ts not found at {:?}",
859                    path
860                );
861                return;
862            }
863            path
864        };
865
866        // Create the workspace
867        let buffer_id_num: usize = buffer_id.0;
868        match PluginDevWorkspace::create(buffer_id_num, content, &fresh_dts_path) {
869            Ok(workspace) => {
870                let plugin_file = workspace.plugin_file.clone();
871
872                // Update buffer metadata to point at the temp file, enabling LSP
873                if let Some(metadata) = self.buffer_metadata.get_mut(&buffer_id) {
874                    if let Some(uri) = super::types::file_path_to_lsp_uri(&plugin_file) {
875                        metadata.kind = super::types::BufferKind::File {
876                            path: plugin_file.clone(),
877                            uri: Some(uri),
878                        };
879                        metadata.lsp_enabled = true;
880                        metadata.lsp_disabled_reason = None;
881                        // Clear any previous LSP opened state so didOpen is sent fresh
882                        metadata.lsp_opened_with.clear();
883
884                        tracing::info!(
885                            "Plugin dev LSP enabled for buffer {} via {:?}",
886                            buffer_id_num,
887                            plugin_file
888                        );
889                    }
890                }
891
892                // Set buffer language to TypeScript so LSP requests use the right handle
893                if let Some(state) = self.buffers.get_mut(&buffer_id) {
894                    let detected =
895                        crate::primitives::detected_language::DetectedLanguage::from_path(
896                            &plugin_file,
897                            &self.grammar_registry,
898                            &self.config.languages,
899                        );
900                    state.apply_language(detected);
901                }
902
903                // Allow TypeScript language so LSP auto-spawns
904                if let Some(lsp) = &mut self.lsp {
905                    lsp.allow_language("typescript");
906                }
907
908                // Store workspace for cleanup
909                let workspace_dir = workspace.dir().to_path_buf();
910                self.plugin_dev_workspaces.insert(buffer_id, workspace);
911
912                // Actually spawn the LSP server and send didOpen for this buffer
913                self.send_lsp_did_open_for_buffer(buffer_id, "typescript");
914
915                // Add the plugin workspace folder so tsserver discovers tsconfig.json + fresh.d.ts
916                if let Some(lsp) = &self.lsp {
917                    if let Some(handle) = lsp.get_handle("typescript") {
918                        if let Some(uri) = super::types::file_path_to_lsp_uri(&workspace_dir) {
919                            let name = workspace_dir
920                                .file_name()
921                                .unwrap_or_default()
922                                .to_string_lossy()
923                                .into_owned();
924                            if let Err(e) = handle.add_workspace_folder(uri, name) {
925                                tracing::warn!("Failed to add plugin workspace folder: {}", e);
926                            } else {
927                                tracing::info!(
928                                    "Added plugin workspace folder: {:?}",
929                                    workspace_dir
930                                );
931                            }
932                        }
933                    }
934                }
935            }
936            Err(e) => {
937                tracing::warn!("Failed to create plugin dev workspace: {}", e);
938            }
939        }
940    }
941}