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                    .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                        .iter_mut()
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    /// Toggle folding at the current cursor position.
316    pub fn toggle_fold_at_cursor(&mut self) {
317        let buffer_id = self.active_buffer();
318        let pos = self.active_cursors().primary().position;
319        self.toggle_fold_at_byte(buffer_id, pos);
320    }
321
322    /// Toggle folding for the given line in the specified buffer.
323    ///
324    /// Kept for callers that only have a line number (e.g. gutter clicks
325    /// that already resolved the line).  Converts to a byte position and
326    /// delegates to [`Self::toggle_fold_at_byte`].
327    pub fn toggle_fold_at_line(&mut self, buffer_id: BufferId, line: usize) {
328        let byte_pos = {
329            let Some(state) = self.buffers.get(&buffer_id) else {
330                return;
331            };
332            state.buffer.line_start_offset(line).unwrap_or_else(|| {
333                use crate::view::folding::indent_folding;
334                let approx = line * state.buffer.estimated_line_length();
335                indent_folding::find_line_start_byte(&state.buffer, approx)
336            })
337        };
338        self.toggle_fold_at_byte(buffer_id, byte_pos);
339    }
340
341    /// Toggle folding at the given byte position in the specified buffer.
342    pub fn toggle_fold_at_byte(&mut self, buffer_id: BufferId, byte_pos: usize) {
343        let split_id = self.split_manager.active_split();
344        let (buffers, split_view_states) = (&mut self.buffers, &mut self.split_view_states);
345
346        let Some(state) = buffers.get_mut(&buffer_id) else {
347            return;
348        };
349
350        let Some(view_state) = split_view_states.get_mut(&split_id) else {
351            return;
352        };
353        let buf_state = view_state.ensure_buffer_state(buffer_id);
354
355        // Try to unfold first — check if this byte's line is a fold header.
356        let header_byte = {
357            use crate::view::folding::indent_folding;
358            indent_folding::find_line_start_byte(&state.buffer, byte_pos)
359        };
360        if buf_state
361            .folds
362            .remove_by_header_byte(&state.buffer, &mut state.marker_list, header_byte)
363        {
364            return;
365        }
366
367        // Also unfold if the byte position is inside an existing fold.
368        if buf_state
369            .folds
370            .remove_if_contains_byte(&mut state.marker_list, byte_pos)
371        {
372            return;
373        }
374
375        // Determine the fold byte range: prefer LSP ranges, fall back to indent-based.
376        if !state.folding_ranges.is_empty() {
377            // --- LSP-provided ranges (line-based) ---
378            // LSP ranges use line numbers, so we need get_line_number here.
379            let line = state.buffer.get_line_number(byte_pos);
380            let mut exact_range: Option<&lsp_types::FoldingRange> = None;
381            let mut exact_span = usize::MAX;
382            let mut containing_range: Option<&lsp_types::FoldingRange> = None;
383            let mut containing_span = usize::MAX;
384
385            for range in &state.folding_ranges {
386                let start_line = range.start_line as usize;
387                let range_end = range.end_line as usize;
388                if range_end <= start_line {
389                    continue;
390                }
391                let span = range_end.saturating_sub(start_line);
392
393                if start_line == line && span < exact_span {
394                    exact_span = span;
395                    exact_range = Some(range);
396                }
397                if start_line <= line && line <= range_end && span < containing_span {
398                    containing_span = span;
399                    containing_range = Some(range);
400                }
401            }
402
403            let chosen = exact_range.or(containing_range);
404            let Some(range) = chosen else {
405                return;
406            };
407            let placeholder = range
408                .collapsed_text
409                .as_ref()
410                .filter(|text| !text.trim().is_empty())
411                .cloned();
412            let header_line = range.start_line as usize;
413            let end_line = range.end_line as usize;
414            let first_hidden = header_line.saturating_add(1);
415            if first_hidden > end_line {
416                return;
417            }
418            let Some(sb) = state.buffer.line_start_offset(first_hidden) else {
419                return;
420            };
421            let eb = state
422                .buffer
423                .line_start_offset(end_line.saturating_add(1))
424                .unwrap_or_else(|| state.buffer.len());
425            let hb = state.buffer.line_start_offset(header_line).unwrap_or(0);
426            Self::create_fold(state, buf_state, sb, eb, hb, placeholder);
427        } else {
428            // --- Indent-based folding on bytes ---
429            use crate::view::folding::indent_folding;
430            let tab_size = state.buffer_settings.tab_size;
431            let max_upward = crate::config::INDENT_FOLD_MAX_UPWARD_SCAN;
432            let est_ll = state.buffer.estimated_line_length();
433            let max_scan_bytes = crate::config::INDENT_FOLD_MAX_SCAN_LINES * est_ll;
434
435            // Ensure the region around the cursor is loaded from disk so the
436            // immutable slice_bytes in find_fold_range_at_byte can read it.
437            let upward_bytes = max_upward * est_ll;
438            let load_start = byte_pos.saturating_sub(upward_bytes);
439            let load_end = byte_pos
440                .saturating_add(max_scan_bytes)
441                .min(state.buffer.len());
442            // Load chunks from disk so immutable slice_bytes in
443            // find_fold_range_at_byte can read the region.
444            drop(
445                state
446                    .buffer
447                    .get_text_range_mut(load_start, load_end - load_start),
448            );
449
450            if let Some((hb, sb, eb)) = indent_folding::find_fold_range_at_byte(
451                &state.buffer,
452                byte_pos,
453                tab_size,
454                max_scan_bytes,
455                max_upward,
456            ) {
457                Self::create_fold(state, buf_state, sb, eb, hb, None);
458            }
459        }
460    }
461
462    fn create_fold(
463        state: &mut crate::state::EditorState,
464        buf_state: &mut crate::view::split::BufferViewState,
465        start_byte: usize,
466        end_byte: usize,
467        header_byte: usize,
468        placeholder: Option<String>,
469    ) {
470        if end_byte <= start_byte {
471            return;
472        }
473
474        // Move any cursors inside the soon-to-be-hidden range to the header line.
475        buf_state.cursors.map(|cursor| {
476            let in_hidden_range = cursor.position >= start_byte && cursor.position < end_byte;
477            let anchor_in_hidden = cursor
478                .anchor
479                .is_some_and(|anchor| anchor >= start_byte && anchor < end_byte);
480            if in_hidden_range || anchor_in_hidden {
481                cursor.position = header_byte;
482                cursor.anchor = None;
483                cursor.sticky_column = 0;
484                cursor.selection_mode = crate::model::cursor::SelectionMode::Normal;
485                cursor.block_anchor = None;
486                cursor.deselect_on_move = true;
487            }
488        });
489
490        buf_state
491            .folds
492            .add(&mut state.marker_list, start_byte, end_byte, placeholder);
493
494        // If the viewport top is now inside the folded range, move it to the header.
495        if buf_state.viewport.top_byte >= start_byte && buf_state.viewport.top_byte < end_byte {
496            buf_state.viewport.top_byte = header_byte;
497            buf_state.viewport.top_view_line_offset = 0;
498        }
499    }
500
501    /// Send didClose to a specific named server for all buffers of a language.
502    ///
503    /// Used when stopping a single server out of multiple for the same language,
504    /// where we don't want to fully disable LSP for the buffers.
505    pub(crate) fn send_did_close_to_server(&mut self, language: &str, server_name: &str) {
506        let uris: Vec<_> = self
507            .buffers
508            .iter()
509            .filter(|(_, s)| s.language == language)
510            .filter_map(|(id, _)| {
511                self.buffer_metadata
512                    .get(id)
513                    .and_then(|m| m.file_uri())
514                    .cloned()
515            })
516            .collect();
517
518        if let Some(lsp) = self.lsp.as_mut() {
519            for sh in lsp.get_handles_mut(language) {
520                if sh.name == server_name {
521                    for uri in &uris {
522                        tracing::info!(
523                            "Sending didClose for {} to '{}' (language: {})",
524                            uri.as_str(),
525                            sh.name,
526                            language
527                        );
528                        if let Err(e) = sh.handle.did_close(uri.clone()) {
529                            tracing::warn!("Failed to send didClose to '{}': {}", sh.name, e);
530                        }
531                    }
532                    break;
533                }
534            }
535        }
536    }
537
538    /// Disable LSP for a specific buffer and clear all LSP-related data
539    pub(crate) fn disable_lsp_for_buffer(&mut self, buffer_id: crate::model::event::BufferId) {
540        // Send didClose to the LSP server so it removes the document from its
541        // tracking. This is critical: without didClose, the async handler's
542        // document_versions still has the path, and should_skip_did_open will
543        // block the didOpen when LSP is re-enabled — causing a desync where
544        // the server has stale content. (GitHub issue #952)
545        if let Some(uri) = self
546            .buffer_metadata
547            .get(&buffer_id)
548            .and_then(|m| m.file_uri())
549            .cloned()
550        {
551            let language = self
552                .buffers
553                .get(&buffer_id)
554                .map(|s| s.language.clone())
555                .unwrap_or_default();
556            if let Some(lsp) = self.lsp.as_mut() {
557                // Broadcast didClose to all handles for this language
558                let handles = lsp.get_handles_mut(&language);
559                if handles.is_empty() {
560                    tracing::warn!(
561                        "disable_lsp_for_buffer: no handle for language '{}'",
562                        language
563                    );
564                } else {
565                    for sh in handles {
566                        tracing::info!(
567                            "Sending didClose for {} to '{}' (language: {})",
568                            uri.as_str(),
569                            sh.name,
570                            language
571                        );
572                        if let Err(e) = sh.handle.did_close(uri.clone()) {
573                            tracing::warn!("Failed to send didClose to '{}': {}", sh.name, e);
574                        }
575                    }
576                }
577            } else {
578                tracing::warn!("disable_lsp_for_buffer: no LSP manager");
579            }
580        } else {
581            tracing::warn!("disable_lsp_for_buffer: no URI for buffer");
582        }
583
584        // Disable LSP in metadata
585        if let Some(metadata) = self.buffer_metadata.get_mut(&buffer_id) {
586            metadata.disable_lsp(t!("lsp.disabled.user").to_string());
587            // Clear LSP opened tracking so it will be sent again if re-enabled
588            metadata.lsp_opened_with.clear();
589        }
590        self.set_status_message(t!("lsp.disabled_for_buffer").to_string());
591
592        // Clear diagnostics for this buffer
593        let uri = self
594            .buffer_metadata
595            .get(&buffer_id)
596            .and_then(|m| m.file_uri())
597            .map(|u| u.as_str().to_string());
598
599        if let Some(uri_str) = uri {
600            self.stored_diagnostics.remove(&uri_str);
601            self.stored_push_diagnostics.remove(&uri_str);
602            self.stored_pull_diagnostics.remove(&uri_str);
603            self.diagnostic_result_ids.remove(&uri_str);
604            self.stored_folding_ranges.remove(&uri_str);
605        }
606
607        // Cancel scheduled diagnostic pull if it targets this buffer
608        if let Some((scheduled_buf, _)) = &self.scheduled_diagnostic_pull {
609            if *scheduled_buf == buffer_id {
610                self.scheduled_diagnostic_pull = None;
611            }
612        }
613
614        self.folding_ranges_in_flight.remove(&buffer_id);
615        self.folding_ranges_debounce.remove(&buffer_id);
616        self.pending_folding_range_requests
617            .retain(|_, req| req.buffer_id != buffer_id);
618
619        // Clear all LSP-related overlays for this buffer (diagnostics + inlay hints)
620        let diagnostic_ns = crate::services::lsp::diagnostics::lsp_diagnostic_namespace();
621        let (buffers, split_view_states) = (&mut self.buffers, &mut self.split_view_states);
622        if let Some(state) = buffers.get_mut(&buffer_id) {
623            state
624                .overlays
625                .clear_namespace(&diagnostic_ns, &mut state.marker_list);
626            state.virtual_texts.clear(&mut state.marker_list);
627            state.folding_ranges.clear();
628            for view_state in split_view_states.values_mut() {
629                if let Some(buf_state) = view_state.keyed_states.get_mut(&buffer_id) {
630                    buf_state.folds.clear(&mut state.marker_list);
631                }
632            }
633        }
634    }
635
636    /// Enable LSP for a specific buffer and send didOpen notification
637    fn enable_lsp_for_buffer(
638        &mut self,
639        buffer_id: crate::model::event::BufferId,
640        language: &str,
641        file_path: Option<std::path::PathBuf>,
642    ) {
643        // Re-enable LSP in metadata
644        if let Some(metadata) = self.buffer_metadata.get_mut(&buffer_id) {
645            metadata.lsp_enabled = true;
646            metadata.lsp_disabled_reason = None;
647        }
648        self.set_status_message(t!("lsp.enabled_for_buffer").to_string());
649
650        // Send didOpen if we have a file path
651        if let Some(_path) = file_path {
652            self.send_lsp_did_open_for_buffer(buffer_id, language);
653        }
654    }
655
656    /// Send LSP didOpen notification for a buffer
657    fn send_lsp_did_open_for_buffer(
658        &mut self,
659        buffer_id: crate::model::event::BufferId,
660        language: &str,
661    ) {
662        // Get the URI and buffer text
663        let (uri, text) = {
664            let metadata = self.buffer_metadata.get(&buffer_id);
665            let uri = metadata.and_then(|m| m.file_uri()).cloned();
666            let text = self
667                .buffers
668                .get(&buffer_id)
669                .and_then(|state| state.buffer.to_string());
670            (uri, text)
671        };
672
673        let Some(uri) = uri else { return };
674        let Some(text) = text else { return };
675
676        // Try to spawn and send didOpen
677        use crate::services::lsp::manager::LspSpawnResult;
678        let file_path = self
679            .buffer_metadata
680            .get(&buffer_id)
681            .and_then(|m| m.file_path())
682            .cloned();
683        let Some(lsp) = self.lsp.as_mut() else {
684            return;
685        };
686
687        if lsp.try_spawn(language, file_path.as_deref()) != LspSpawnResult::Spawned {
688            return;
689        }
690
691        let Some(handle) = lsp.get_handle_mut(language) else {
692            return;
693        };
694
695        let handle_id = handle.id();
696        if let Err(e) = handle.did_open(uri.clone(), text, language.to_string()) {
697            tracing::warn!("Failed to send didOpen to LSP: {}", e);
698            return;
699        }
700
701        // Mark buffer as opened with this server
702        if let Some(metadata) = self.buffer_metadata.get_mut(&buffer_id) {
703            metadata.lsp_opened_with.insert(handle_id);
704        }
705
706        // Request diagnostics
707        let request_id = self.next_lsp_request_id;
708        self.next_lsp_request_id += 1;
709        let previous_result_id = self.diagnostic_result_ids.get(uri.as_str()).cloned();
710        if let Err(e) = handle.document_diagnostic(request_id, uri.clone(), previous_result_id) {
711            tracing::warn!("LSP document_diagnostic request failed: {}", e);
712        }
713
714        // Request inlay hints if enabled
715        if self.config.editor.enable_inlay_hints {
716            let (last_line, last_char) = self
717                .buffers
718                .get(&buffer_id)
719                .map(|state| {
720                    let line_count = state.buffer.line_count().unwrap_or(1000);
721                    (line_count.saturating_sub(1) as u32, 10000u32)
722                })
723                .unwrap_or((999, 10000));
724
725            let request_id = self.next_lsp_request_id;
726            self.next_lsp_request_id += 1;
727            if let Err(e) = handle.inlay_hints(request_id, uri, 0, 0, last_line, last_char) {
728                tracing::warn!("LSP inlay_hints request failed: {}", e);
729            }
730        }
731
732        // Schedule folding range refresh
733        self.schedule_folding_ranges_refresh(buffer_id);
734    }
735
736    /// Set up a plugin development workspace for LSP support on a buffer.
737    ///
738    /// Creates a temp directory with `fresh.d.ts` + `tsconfig.json` so that
739    /// `typescript-language-server` can provide autocomplete and type checking
740    /// for plugin buffers (including unsaved/unnamed ones).
741    pub(crate) fn setup_plugin_dev_lsp(&mut self, buffer_id: BufferId, content: &str) {
742        use crate::services::plugins::plugin_dev_workspace::PluginDevWorkspace;
743
744        // Use the exact cached extraction location for fresh.d.ts
745        #[cfg(feature = "embed-plugins")]
746        let fresh_dts_path = {
747            let Some(embedded_dir) = crate::services::plugins::embedded::get_embedded_plugins_dir()
748            else {
749                tracing::warn!(
750                    "Cannot set up plugin dev LSP: embedded plugins directory not available"
751                );
752                return;
753            };
754            let path = embedded_dir.join("lib").join("fresh.d.ts");
755            if !path.exists() {
756                tracing::warn!(
757                    "Cannot set up plugin dev LSP: fresh.d.ts not found at {:?}",
758                    path
759                );
760                return;
761            }
762            path
763        };
764
765        #[cfg(not(feature = "embed-plugins"))]
766        let fresh_dts_path = {
767            // In non-embedded builds (development), use the source tree path
768            let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
769                .join("plugins")
770                .join("lib")
771                .join("fresh.d.ts");
772            if !path.exists() {
773                tracing::warn!(
774                    "Cannot set up plugin dev LSP: fresh.d.ts not found at {:?}",
775                    path
776                );
777                return;
778            }
779            path
780        };
781
782        // Create the workspace
783        let buffer_id_num: usize = buffer_id.0;
784        match PluginDevWorkspace::create(buffer_id_num, content, &fresh_dts_path) {
785            Ok(workspace) => {
786                let plugin_file = workspace.plugin_file.clone();
787
788                // Update buffer metadata to point at the temp file, enabling LSP
789                if let Some(metadata) = self.buffer_metadata.get_mut(&buffer_id) {
790                    if let Some(uri) = super::types::file_path_to_lsp_uri(&plugin_file) {
791                        metadata.kind = super::types::BufferKind::File {
792                            path: plugin_file.clone(),
793                            uri: Some(uri),
794                        };
795                        metadata.lsp_enabled = true;
796                        metadata.lsp_disabled_reason = None;
797                        // Clear any previous LSP opened state so didOpen is sent fresh
798                        metadata.lsp_opened_with.clear();
799
800                        tracing::info!(
801                            "Plugin dev LSP enabled for buffer {} via {:?}",
802                            buffer_id_num,
803                            plugin_file
804                        );
805                    }
806                }
807
808                // Set buffer language to TypeScript so LSP requests use the right handle
809                if let Some(state) = self.buffers.get_mut(&buffer_id) {
810                    let detected =
811                        crate::primitives::detected_language::DetectedLanguage::from_path(
812                            &plugin_file,
813                            &self.grammar_registry,
814                            &self.config.languages,
815                        );
816                    state.apply_language(detected);
817                }
818
819                // Allow TypeScript language so LSP auto-spawns
820                if let Some(lsp) = &mut self.lsp {
821                    lsp.allow_language("typescript");
822                }
823
824                // Store workspace for cleanup
825                let workspace_dir = workspace.dir().to_path_buf();
826                self.plugin_dev_workspaces.insert(buffer_id, workspace);
827
828                // Actually spawn the LSP server and send didOpen for this buffer
829                self.send_lsp_did_open_for_buffer(buffer_id, "typescript");
830
831                // Add the plugin workspace folder so tsserver discovers tsconfig.json + fresh.d.ts
832                if let Some(lsp) = &self.lsp {
833                    if let Some(handle) = lsp.get_handle("typescript") {
834                        if let Some(uri) = super::types::file_path_to_lsp_uri(&workspace_dir) {
835                            let name = workspace_dir
836                                .file_name()
837                                .unwrap_or_default()
838                                .to_string_lossy()
839                                .into_owned();
840                            if let Err(e) = handle.add_workspace_folder(uri, name) {
841                                tracing::warn!("Failed to add plugin workspace folder: {}", e);
842                            } else {
843                                tracing::info!(
844                                    "Added plugin workspace folder: {:?}",
845                                    workspace_dir
846                                );
847                            }
848                        }
849                    }
850                }
851            }
852            Err(e) => {
853                tracing::warn!("Failed to create plugin dev workspace: {}", e);
854            }
855        }
856    }
857}