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
21            .windows
22            .get(&self.active_window)
23            .map(|w| &w.buffers)
24            .expect("active window present")
25            .get(&buffer_id)
26        else {
27            return;
28        };
29        let language = state.language.clone();
30        let file_path = self
31            .active_window()
32            .buffer_metadata
33            .get(&buffer_id)
34            .and_then(|meta| meta.file_path().cloned());
35
36        // Get configured servers for this language
37        let configs: Vec<_> = self
38            .lsp()
39            .as_ref()
40            .and_then(|lsp| lsp.get_configs(&language))
41            .map(|c| c.to_vec())
42            .unwrap_or_default();
43
44        if configs.is_empty() {
45            self.set_status_message(t!("lsp.no_server_configured").to_string());
46            return;
47        }
48
49        // Single server: restart immediately without a prompt (backward compat)
50        if configs.len() == 1 {
51            let __active_id = self.active_window;
52            let Some(lsp) = self
53                .windows
54                .get_mut(&__active_id)
55                .and_then(|w| w.lsp.as_mut())
56            else {
57                self.set_status_message(t!("lsp.no_manager").to_string());
58                return;
59            };
60
61            let (success, message) = lsp.manual_restart(&language, file_path.as_deref());
62            self.active_window_mut().status_message = Some(message);
63
64            if success {
65                self.reopen_buffers_for_language(&language);
66            }
67            return;
68        }
69
70        // Multiple servers: show a prompt
71        let mut suggestions: Vec<Suggestion> = Vec::new();
72
73        // Default option: restart all enabled servers
74        let enabled_names: Vec<_> = configs
75            .iter()
76            .filter(|c| c.enabled && !c.command.is_empty())
77            .map(|c| c.display_name())
78            .collect();
79        let all_description = if enabled_names.is_empty() {
80            Some("No enabled servers".to_string())
81        } else {
82            Some(enabled_names.join(", "))
83        };
84        suggestions.push(Suggestion {
85            text: format!("{} (all enabled)", language),
86            description: all_description,
87            value: Some(language.clone()),
88            disabled: enabled_names.is_empty(),
89            keybinding: None,
90            source: None,
91        });
92
93        // Individual server options
94        for config in &configs {
95            if config.command.is_empty() {
96                continue;
97            }
98            let name = config.display_name();
99            let status = if config.enabled { "" } else { " [disabled]" };
100            suggestions.push(Suggestion {
101                text: format!("{}/{}{}", language, name, status),
102                description: Some(format!("Command: {}", config.command)),
103                value: Some(format!("{}/{}", language, name)),
104                disabled: false,
105                keybinding: None,
106                source: None,
107            });
108        }
109
110        // Start prompt with suggestions
111        self.active_window_mut().prompt = Some(Prompt::with_suggestions(
112            "Restart LSP server: ".to_string(),
113            PromptType::RestartLspServer,
114            suggestions.clone(),
115        ));
116
117        // Configure initial selection
118        if let Some(prompt) = self.active_window_mut().prompt.as_mut() {
119            prompt.selected_suggestion = Some(0);
120        }
121    }
122
123    /// Send didOpen notifications for all buffers of a given language to any
124    /// server handles that haven't received them yet.
125    ///
126    /// Called after an LSP server starts or restarts so it immediately knows
127    /// about every open file (rather than waiting for the next user edit).
128    pub(crate) fn reopen_buffers_for_language(&mut self, language: &str) {
129        // Collect buffer info first to avoid borrow conflicts
130        // Use buffer's stored language rather than detecting from path
131        let buffers_for_language: Vec<_> = self
132            .buffers()
133            .iter()
134            .filter_map(|(buf_id, state)| {
135                if state.language == language {
136                    self.active_window()
137                        .buffer_metadata
138                        .get(buf_id)
139                        .and_then(|meta| meta.file_path().map(|p| (*buf_id, p.clone())))
140                } else {
141                    None
142                }
143            })
144            .collect();
145
146        let enable_inlay_hints = self.config.editor.enable_inlay_hints;
147
148        for (buffer_id, buf_path) in buffers_for_language {
149            let Some(state) = self
150                .windows
151                .get(&self.active_window)
152                .map(|w| &w.buffers)
153                .expect("active window present")
154                .get(&buffer_id)
155            else {
156                continue;
157            };
158
159            let Some(content) = state.buffer.to_string() else {
160                continue; // Skip buffers that aren't fully loaded
161            };
162
163            let Some(uri) = super::types::file_path_to_lsp_uri_with_translation(
164                &buf_path,
165                self.authority.path_translation.as_ref(),
166            ) else {
167                continue;
168            };
169
170            let lang_id = state.language.clone();
171            let line_count = state.buffer.line_count().unwrap_or(1000);
172            let buffer_version = state.buffer.version();
173
174            let __active_id = self.active_window;
175
176            if let Some(__win) = self.windows.get_mut(&__active_id) {
177                let Some(lsp) = __win.lsp.as_mut() else {
178                    continue;
179                };
180                // Respect auto_start setting for this user action
181                use crate::services::lsp::manager::LspSpawnResult;
182                if lsp.try_spawn(&lang_id, Some(&buf_path)) != LspSpawnResult::Spawned {
183                    continue;
184                }
185
186                // Collect handles that need didOpen (not yet tracked in
187                // lsp_opened_with for this buffer).
188                let opened_with = __win
189                    .buffer_metadata
190                    .get(&buffer_id)
191                    .map(|m| m.lsp_opened_with.clone())
192                    .unwrap_or_default();
193
194                let handles_needing_open: Vec<(String, u64)> = lsp
195                    .get_handles(&lang_id)
196                    .into_iter()
197                    .filter(|sh| !opened_with.contains(&sh.handle.id()))
198                    .map(|sh| (sh.name.clone(), sh.handle.id()))
199                    .collect();
200
201                // Send didOpen to each handle that hasn't seen this buffer yet
202                for (name, handle_id) in handles_needing_open {
203                    let sh = lsp
204                        .get_handles_mut(&lang_id)
205                        .into_iter()
206                        .find(|s| s.handle.id() == handle_id);
207
208                    if let Some(sh) = sh {
209                        if let Err(e) =
210                            sh.handle
211                                .did_open(uri.clone(), content.clone(), lang_id.clone())
212                        {
213                            tracing::warn!("LSP did_open to '{}' failed: {}", name, e);
214                        } else if let Some(metadata) = __win.buffer_metadata.get_mut(&buffer_id) {
215                            metadata.lsp_opened_with.insert(handle_id);
216                        }
217                    }
218                }
219            }
220
221            // Kick off inlay hints for this buffer right after (re)opening.
222            // Servers that emit a `serverQuiescent` notification (e.g.
223            // rust-analyzer) will refresh these later once indexing is
224            // done, but servers that don't would otherwise never get a
225            // hints request unless the user edits the buffer.
226            if enable_inlay_hints {
227                let __active_id = self.active_window;
228                if let Some(__win) = self.windows.get_mut(&__active_id) {
229                    let __next_id = &mut __win.next_lsp_request_id;
230                    let __pending = &mut __win.pending_inlay_hints_requests;
231                    if let Some(lsp) = __win.lsp.as_mut() {
232                        if let Some(sh) = lsp
233                            .handle_for_feature_mut(&lang_id, crate::types::LspFeature::InlayHints)
234                        {
235                            let request_id = *__next_id;
236                            *__next_id += 1;
237                            let last_line = line_count.saturating_sub(1) as u32;
238                            if let Err(e) = sh.handle.inlay_hints(
239                                request_id,
240                                uri.clone(),
241                                0,
242                                0,
243                                last_line,
244                                10000,
245                            ) {
246                                tracing::debug!(
247                                    "Failed to request inlay hints for {}: {}",
248                                    uri.as_str(),
249                                    e
250                                );
251                            } else {
252                                __pending.insert(
253                                    request_id,
254                                    super::InlayHintsRequest {
255                                        buffer_id,
256                                        version: buffer_version,
257                                    },
258                                );
259                            }
260                        }
261                    }
262                }
263            }
264        }
265    }
266
267    /// Handle the LspStop action.
268    ///
269    /// Shows a prompt to select which LSP server to stop, with suggestions
270    /// for all currently running servers.
271    pub fn handle_lsp_stop(&mut self) {
272        let running_languages: Vec<String> = self
273            .lsp()
274            .as_ref()
275            .map(|lsp| lsp.running_servers())
276            .unwrap_or_default();
277
278        if running_languages.is_empty() {
279            self.set_status_message(t!("lsp.no_servers_running").to_string());
280            return;
281        }
282
283        // Build suggestions showing server names when multiple servers per language
284        let mut suggestions: Vec<Suggestion> = Vec::new();
285        for lang in &running_languages {
286            let server_names: Vec<String> = self
287                .lsp()
288                .as_ref()
289                .map(|lsp| lsp.server_names_for_language(lang))
290                .unwrap_or_default();
291
292            if server_names.len() > 1 {
293                // Multiple servers: show each individually
294                for name in &server_names {
295                    let description = Some(format!("Server: {}", name));
296                    suggestions.push(Suggestion {
297                        text: format!("{}/{}", lang, name),
298                        description,
299                        // Value carries "language/server_name" so the handler
300                        // knows exactly which server to stop.
301                        value: Some(format!("{}/{}", lang, name)),
302                        disabled: false,
303                        keybinding: None,
304                        source: None,
305                    });
306                }
307            } else {
308                // Single server: show language only (value = just language)
309                let description = self
310                    .lsp()
311                    .as_ref()
312                    .and_then(|lsp| lsp.get_config(lang))
313                    .filter(|c| !c.command.is_empty())
314                    .map(|c| format!("Command: {}", c.command));
315
316                suggestions.push(Suggestion {
317                    text: lang.clone(),
318                    description,
319                    value: Some(lang.clone()),
320                    disabled: false,
321                    keybinding: None,
322                    source: None,
323                });
324            }
325        }
326
327        // Start prompt with suggestions
328        self.active_window_mut().prompt = Some(Prompt::with_suggestions(
329            "Stop LSP server: ".to_string(),
330            PromptType::StopLspServer,
331            suggestions.clone(),
332        ));
333
334        // Configure initial selection
335        if let Some(prompt) = self.active_window_mut().prompt.as_mut() {
336            if suggestions.len() == 1 {
337                // If only one entry, pre-fill the input with it
338                prompt.input = suggestions[0].text.clone();
339                prompt.cursor_pos = prompt.input.len();
340                prompt.selected_suggestion = Some(0);
341            } else if !prompt.suggestions.is_empty() {
342                // Auto-select first suggestion
343                prompt.selected_suggestion = Some(0);
344            }
345        }
346    }
347
348    /// Handle the LspToggleForBuffer action.
349    ///
350    /// Toggles LSP on/off for the current buffer only.
351    /// Requires an LSP server to be configured for the current buffer's language.
352    pub fn handle_lsp_toggle_for_buffer(&mut self) {
353        let buffer_id = self.active_buffer();
354
355        // Get the buffer's language to check if LSP is configured
356        let language = {
357            let Some(state) = self
358                .windows
359                .get(&self.active_window)
360                .map(|w| &w.buffers)
361                .expect("active window present")
362                .get(&buffer_id)
363            else {
364                return;
365            };
366            state.language.clone()
367        };
368
369        // Check if LSP is configured for this language
370        let lsp_configured = self
371            .lsp()
372            .as_ref()
373            .and_then(|lsp| lsp.get_config(&language))
374            .is_some();
375
376        if !lsp_configured {
377            self.set_status_message(t!("lsp.no_server_configured").to_string());
378            return;
379        }
380
381        // Check current LSP state
382        let (was_enabled, file_path) = {
383            let Some(metadata) = self.active_window().buffer_metadata.get(&buffer_id) else {
384                return;
385            };
386            (metadata.lsp_enabled, metadata.file_path().cloned())
387        };
388
389        if was_enabled {
390            self.disable_lsp_for_buffer(buffer_id);
391        } else {
392            self.enable_lsp_for_buffer(buffer_id, &language, file_path);
393        }
394    }
395
396    /// Handle an action from the LSP status details popup.
397    ///
398    /// Action keys have the format:
399    /// - `restart:<language>/<server_name>` — restart a specific server
400    /// - `start:<language>` — start LSP server(s) for a language
401    /// - `stop:<language>/<server_name>` — stop a specific server
402    /// - `log:<language>` — open the LSP log file for the language
403    /// - `dismiss:<language>` — hide the pill for this language (dim style)
404    /// - `enable:<language>` — restore a dismissed language's pill
405    /// - `autostart:<language>/<server_name>` — flip auto_start=true for
406    ///   the named server in config, save, and start it now
407    /// - `cancel_popup` — no-op here; the row exists purely so the
408    ///   user has an on-screen "Dismiss" affordance (close is handled
409    ///   upstream in `handle_popup_confirm` before this is called)
410    pub fn handle_lsp_status_action(&mut self, action_key: &str) {
411        // Plugin-contributed rows in the LSP-Servers popup carry an
412        // action_key like `plugin:{plugin_id}|{item_id}`. The popup
413        // builder injects these from
414        // `Window::lsp_menu_contributions`; selecting one fires the
415        // standard `action_popup_result` hook so the contributing
416        // plugin can react. The `popup_id` is the fixed string
417        // "lsp_status" so a plugin can disambiguate from popups it
418        // owns directly via `editor.showActionPopup`.
419        if let Some(rest) = action_key.strip_prefix("plugin:") {
420            let (plugin_id, item_id) = rest.split_once('|').unwrap_or((rest, ""));
421            self.plugin_manager.read().unwrap().run_hook(
422                "action_popup_result",
423                crate::services::plugins::hooks::HookArgs::ActionPopupResult {
424                    popup_id: "lsp_status".to_string(),
425                    action_id: format!("{}|{}", plugin_id, item_id),
426                },
427            );
428            return;
429        }
430        if action_key == "cancel_popup" {
431            // Popup is already closed by `handle_popup_confirm`; the
432            // row only exists to give the user an on-screen surface
433            // that documents the Esc shortcut. Nothing to do here.
434            return;
435        }
436        if let Some(target) = action_key.strip_prefix("autostart:") {
437            // Persist `auto_start = true` in config so the server
438            // starts automatically on future file opens, then kick it
439            // off right away for the current session. Mirrors the
440            // persisting half of the stop-server prompt path (see
441            // `handle_stop_lsp_server` which sets auto_start=false).
442            if let Some((language, server_name)) = target.split_once('/') {
443                if let Some(lsp_configs) = self.config_mut().lsp.get_mut(language) {
444                    for c in lsp_configs.as_mut_slice() {
445                        if c.display_name() == server_name {
446                            c.auto_start = true;
447                        }
448                    }
449                    if let Err(e) = self.save_config() {
450                        tracing::warn!(
451                            "Failed to save config after enabling LSP auto-start: {}",
452                            e
453                        );
454                    } else {
455                        let config_path = self.dir_context.config_path();
456                        self.emit_event(
457                            "config_changed",
458                            serde_json::json!({
459                                "path": config_path.to_string_lossy(),
460                            }),
461                        );
462                    }
463                }
464
465                // Start the server now so the user doesn't have to
466                // re-open the file to see LSP features come alive.
467                let file_path = self
468                    .active_window()
469                    .buffer_metadata
470                    .get(&self.active_buffer())
471                    .and_then(|meta| meta.file_path().cloned());
472                let __active_id = self.active_window;
473                if let Some(lsp) = self
474                    .windows
475                    .get_mut(&__active_id)
476                    .and_then(|w| w.lsp.as_mut())
477                {
478                    let (_, message) = lsp.manual_restart(language, file_path.as_deref());
479                    self.active_window_mut().status_message = Some(message);
480                }
481                self.reopen_buffers_for_language(language);
482            }
483        } else if let Some(language) = action_key.strip_prefix("start:") {
484            // Start/restart LSP for this language (same as the "Start/Restart LSP" command)
485            let file_path = self
486                .active_window()
487                .buffer_metadata
488                .get(&self.active_buffer())
489                .and_then(|meta| meta.file_path().cloned());
490
491            let __active_id = self.active_window;
492
493            if let Some(lsp) = self
494                .windows
495                .get_mut(&__active_id)
496                .and_then(|w| w.lsp.as_mut())
497            {
498                let (_, message) = lsp.manual_restart(language, file_path.as_deref());
499                self.active_window_mut().status_message = Some(message);
500            } else {
501                self.active_window_mut().status_message =
502                    Some("No LSP manager available".to_string());
503            }
504            self.reopen_buffers_for_language(language);
505        } else if let Some(target) = action_key.strip_prefix("restart:") {
506            // Parse language/server_name
507            if let Some((language, server_name)) = target.split_once('/') {
508                let file_path = self
509                    .active_window()
510                    .buffer_metadata
511                    .get(&self.active_buffer())
512                    .and_then(|meta| meta.file_path().cloned());
513
514                let __active_id = self.active_window;
515
516                if let Some(lsp) = self
517                    .windows
518                    .get_mut(&__active_id)
519                    .and_then(|w| w.lsp.as_mut())
520                {
521                    // Shutdown the specific server first, then re-spawn
522                    lsp.shutdown_server_by_name(language, server_name);
523                }
524                // Remove the status entry so it gets re-created on spawn
525                self.active_window_mut()
526                    .lsp_server_statuses
527                    .remove(&(language.to_string(), server_name.to_string()));
528                let __active_id = self.active_window;
529                if let Some(lsp) = self
530                    .windows
531                    .get_mut(&__active_id)
532                    .and_then(|w| w.lsp.as_mut())
533                {
534                    let _ = lsp.manual_restart(language, file_path.as_deref());
535                }
536                self.reopen_buffers_for_language(language);
537                self.active_window_mut().status_message = Some(format!(
538                    "Restarting LSP server: {}/{}",
539                    language, server_name
540                ));
541            }
542        } else if let Some(target) = action_key.strip_prefix("stop:") {
543            if let Some((language, server_name)) = target.split_once('/') {
544                // Send didClose first so the server drops documents
545                // cleanly; the shared helper then shuts the handle,
546                // clears lsp_server_statuses (so the status-bar pill
547                // flips back off), and clears diagnostics this server
548                // published. The old inline path missed the didClose
549                // and the diagnostic clear.
550                self.send_did_close_to_server(language, server_name);
551                let stopped = self.stop_lsp_server_and_cleanup(language, Some(server_name));
552                if stopped {
553                    self.active_window_mut().status_message =
554                        Some(format!("Stopped LSP server: {}/{}", language, server_name));
555                } else {
556                    self.active_window_mut().status_message = Some(format!(
557                        "LSP server not running: {}/{}",
558                        language, server_name
559                    ));
560                }
561            }
562        } else if let Some(language) = action_key.strip_prefix("log:") {
563            let log_path = crate::services::log_dirs::lsp_log_path(language);
564            if log_path.exists() {
565                match self.active_window_mut().open_local_file(&log_path) {
566                    Ok(buffer_id) => {
567                        self.active_window_mut()
568                            .mark_buffer_read_only(buffer_id, true);
569                    }
570                    Err(e) => {
571                        self.active_window_mut().status_message =
572                            Some(format!("Failed to open LSP log: {}", e));
573                    }
574                }
575            } else {
576                self.active_window_mut().status_message =
577                    Some(format!("No log file found for {}", language));
578            }
579        } else if let Some(language) = action_key.strip_prefix("dismiss:") {
580            // Persist `enabled = false` for every configured server
581            // under this language so the decision survives a restart
582            // — the old behaviour (just marking the language
583            // dismissed in-memory) meant the next editor session
584            // re-prompted the user. We keep the session-level
585            // `user_dismissed_lsp_languages` flag updated too so
586            // anything that still reads it (dimmed pill style, the
587            // popup's Enable/Disable toggle) stays consistent with
588            // the persisted state until the in-memory cache next
589            // re-reads config.
590            let lang = language.to_string();
591            self.active_window_mut().dismiss_lsp_language(&lang);
592            let mut changed = false;
593            if let Some(lsp_configs) = self.config_mut().lsp.get_mut(&lang) {
594                for c in lsp_configs.as_mut_slice() {
595                    if c.enabled {
596                        c.enabled = false;
597                        changed = true;
598                    }
599                }
600            }
601            if changed {
602                if let Err(e) = self.save_config() {
603                    tracing::warn!("Failed to save config after disabling LSP: {}", e);
604                } else {
605                    let config_path = self.dir_context.config_path();
606                    self.emit_event(
607                        "config_changed",
608                        serde_json::json!({
609                            "path": config_path.to_string_lossy(),
610                        }),
611                    );
612                }
613            }
614
615            // Stop any servers currently running for this language —
616            // persisting `enabled = false` alone left the running
617            // server alive (the user saw "LSP disabled" in the status
618            // bar while rust-analyzer kept indexing in the background).
619            // For anything else the user would have picked Stop; this
620            // row's "Disable" label promises both halves. Send didClose
621            // for each server we're about to drop so the server tears
622            // down its document state cleanly before the handle goes
623            // away.
624            let running_server_names: Vec<String> = self
625                .active_window()
626                .lsp_server_statuses
627                .iter()
628                .filter_map(|((l, name), status)| {
629                    if l == &lang
630                        && !matches!(
631                            status,
632                            crate::services::async_bridge::LspServerStatus::Shutdown,
633                        )
634                    {
635                        Some(name.clone())
636                    } else {
637                        None
638                    }
639                })
640                .collect();
641            for name in &running_server_names {
642                self.send_did_close_to_server(&lang, name);
643                self.stop_lsp_server_and_cleanup(&lang, Some(name));
644            }
645
646            self.active_window_mut().status_message = Some(format!("LSP disabled for {}.", lang));
647        } else if let Some(language) = action_key.strip_prefix("enable:") {
648            // Symmetric re-enable: flip `enabled = true` on every
649            // configured server for this language and persist. The
650            // popup's "Enable LSP for <lang>" row is the inverse of
651            // the disable action, so it must undo both halves —
652            // session dismissal and the on-disk flag.
653            let lang = language.to_string();
654            self.active_window_mut().undismiss_lsp_language(&lang);
655            let mut changed = false;
656            if let Some(lsp_configs) = self.config_mut().lsp.get_mut(&lang) {
657                for c in lsp_configs.as_mut_slice() {
658                    if !c.enabled {
659                        c.enabled = true;
660                        changed = true;
661                    }
662                }
663            }
664            if changed {
665                if let Err(e) = self.save_config() {
666                    tracing::warn!("Failed to save config after enabling LSP: {}", e);
667                } else {
668                    let config_path = self.dir_context.config_path();
669                    self.emit_event(
670                        "config_changed",
671                        serde_json::json!({
672                            "path": config_path.to_string_lossy(),
673                        }),
674                    );
675                }
676            }
677            self.active_window_mut().status_message = Some(format!("LSP enabled for {}.", lang));
678        }
679    }
680
681    // `toggle_fold_at_cursor`, `toggle_fold_at_line`, and the
682    // `toggle_fold_at_byte` wrapper live on `impl Window` — call them via
683    // `self.active_window_mut().toggle_fold_at_*(...)`.
684}
685
686impl crate::app::window::Window {
687    /// Toggle folding at the current cursor position in the active buffer.
688    pub fn toggle_fold_at_cursor(&mut self) {
689        let buffer_id = self.active_buffer();
690        let pos = self.active_cursors().primary().position;
691        self.toggle_fold_at_byte(buffer_id, pos);
692    }
693
694    /// Toggle folding for the given line in the specified buffer.
695    ///
696    /// Kept for callers that only have a line number (e.g. gutter clicks
697    /// that already resolved the line).  Converts to a byte position and
698    /// delegates to [`Self::toggle_fold_at_byte`].
699    pub fn toggle_fold_at_line(&mut self, buffer_id: crate::model::event::BufferId, line: usize) {
700        let byte_pos = {
701            let Some(state) = self.buffers.get(&buffer_id) else {
702                return;
703            };
704            state.buffer.line_start_offset(line).unwrap_or_else(|| {
705                use crate::view::folding::indent_folding;
706                let approx = line * state.buffer.estimated_line_length();
707                indent_folding::find_line_start_byte(&state.buffer, approx)
708            })
709        };
710        self.toggle_fold_at_byte(buffer_id, byte_pos);
711    }
712
713    /// Toggle folding at the given byte position in the specified buffer.
714    pub fn toggle_fold_at_byte(
715        &mut self,
716        buffer_id: crate::model::event::BufferId,
717        byte_pos: usize,
718    ) {
719        let Some(split_id) = self.buffers.split_manager().map(|m| m.active_split()) else {
720            return;
721        };
722
723        self.buffers
724            .with_buffer_and_split(buffer_id, split_id, |state, view_state| {
725                let buf_state = view_state.ensure_buffer_state(buffer_id);
726
727                // Try to unfold first — check if this byte's line is a fold header.
728                let header_byte = {
729                    use crate::view::folding::indent_folding;
730                    indent_folding::find_line_start_byte(&state.buffer, byte_pos)
731                };
732                if buf_state.folds.remove_by_header_byte(
733                    &state.buffer,
734                    &mut state.marker_list,
735                    header_byte,
736                ) {
737                    return;
738                }
739
740                // Also unfold if the byte position is inside an existing fold.
741                if buf_state
742                    .folds
743                    .remove_if_contains_byte(&mut state.marker_list, byte_pos)
744                {
745                    return;
746                }
747
748                // Determine the fold byte range: prefer LSP ranges, fall back to indent-based.
749                if !state.folding_ranges.is_empty() {
750                    // --- LSP-provided ranges (line-based) ---
751                    let resolved = state
752                        .folding_ranges
753                        .resolved(&state.buffer, &state.marker_list);
754                    let line = state.buffer.get_line_number(byte_pos);
755                    let mut exact_range: Option<&lsp_types::FoldingRange> = None;
756                    let mut exact_span = usize::MAX;
757                    let mut containing_range: Option<&lsp_types::FoldingRange> = None;
758                    let mut containing_span = usize::MAX;
759
760                    for range in &resolved {
761                        let start_line = range.start_line as usize;
762                        let range_end = range.end_line as usize;
763                        if range_end <= start_line {
764                            continue;
765                        }
766                        let span = range_end.saturating_sub(start_line);
767
768                        if start_line == line && span < exact_span {
769                            exact_span = span;
770                            exact_range = Some(range);
771                        }
772                        if start_line <= line && line <= range_end && span < containing_span {
773                            containing_span = span;
774                            containing_range = Some(range);
775                        }
776                    }
777
778                    let chosen = exact_range.or(containing_range);
779                    let Some(range) = chosen else {
780                        return;
781                    };
782                    let placeholder = range
783                        .collapsed_text
784                        .as_ref()
785                        .filter(|text| !text.trim().is_empty())
786                        .cloned();
787                    let header_line = range.start_line as usize;
788                    let end_line = range.end_line as usize;
789                    let first_hidden = header_line.saturating_add(1);
790                    if first_hidden > end_line {
791                        return;
792                    }
793                    let Some(sb) = state.buffer.line_start_offset(first_hidden) else {
794                        return;
795                    };
796                    let eb = state
797                        .buffer
798                        .line_start_offset(end_line.saturating_add(1))
799                        .unwrap_or_else(|| state.buffer.len());
800                    let hb = state.buffer.line_start_offset(header_line).unwrap_or(0);
801                    create_fold(state, buf_state, sb, eb, hb, placeholder);
802                } else {
803                    // --- Indent-based folding on bytes ---
804                    use crate::view::folding::indent_folding;
805                    let tab_size = state.buffer_settings.tab_size;
806                    let max_upward = crate::config::INDENT_FOLD_MAX_UPWARD_SCAN;
807                    let est_ll = state.buffer.estimated_line_length();
808                    let max_scan_bytes = crate::config::INDENT_FOLD_MAX_SCAN_LINES * est_ll;
809
810                    let upward_bytes = max_upward * est_ll;
811                    let load_start = byte_pos.saturating_sub(upward_bytes);
812                    let load_end = byte_pos
813                        .saturating_add(max_scan_bytes)
814                        .min(state.buffer.len());
815                    drop(
816                        state
817                            .buffer
818                            .get_text_range_mut(load_start, load_end - load_start),
819                    );
820
821                    if let Some((hb, sb, eb)) = indent_folding::find_fold_range_at_byte(
822                        &state.buffer,
823                        byte_pos,
824                        tab_size,
825                        max_scan_bytes,
826                        max_upward,
827                    ) {
828                        create_fold(state, buf_state, sb, eb, hb, None);
829                    }
830                }
831            });
832    }
833}
834
835/// Plant a fold over the byte range, moving cursors out of the
836/// hidden region and re-anchoring the viewport top if it landed
837/// inside the new fold. Free function (not a method) so both
838/// `Window::toggle_fold_at_byte` and any future Editor-side
839/// orchestrator can call it without a `Self::` qualifier.
840fn create_fold(
841    state: &mut crate::state::EditorState,
842    buf_state: &mut crate::view::split::BufferViewState,
843    start_byte: usize,
844    end_byte: usize,
845    header_byte: usize,
846    placeholder: Option<String>,
847) {
848    if end_byte <= start_byte {
849        return;
850    }
851
852    // Move any cursors inside the soon-to-be-hidden range to the header line.
853    buf_state.cursors.map(|cursor| {
854        let in_hidden_range = cursor.position >= start_byte && cursor.position < end_byte;
855        let anchor_in_hidden = cursor
856            .anchor
857            .is_some_and(|anchor| anchor >= start_byte && anchor < end_byte);
858        if in_hidden_range || anchor_in_hidden {
859            cursor.position = header_byte;
860            cursor.anchor = None;
861            cursor.sticky_column = 0;
862            cursor.selection_mode = crate::model::cursor::SelectionMode::Normal;
863            cursor.block_anchor = None;
864            cursor.deselect_on_move = true;
865        }
866    });
867
868    buf_state
869        .folds
870        .add(&mut state.marker_list, start_byte, end_byte, placeholder);
871
872    // If the viewport top is now inside the folded range, move it to the header.
873    if buf_state.viewport.top_byte >= start_byte && buf_state.viewport.top_byte < end_byte {
874        buf_state.viewport.top_byte = header_byte;
875        buf_state.viewport.top_view_line_offset = 0;
876    }
877}
878
879impl Editor {
880    /// Send didClose to a specific named server for all buffers of a language.
881    ///
882    /// Used when stopping a single server out of multiple for the same language,
883    /// where we don't want to fully disable LSP for the buffers.
884    pub(crate) fn send_did_close_to_server(&mut self, language: &str, server_name: &str) {
885        let uris: Vec<_> = self
886            .buffers()
887            .iter()
888            .filter(|(_, s)| s.language == language)
889            .filter_map(|(id, _)| {
890                self.active_window()
891                    .buffer_metadata
892                    .get(id)
893                    .and_then(|m| m.file_uri())
894                    .cloned()
895            })
896            .collect();
897
898        let __active_id = self.active_window;
899
900        if let Some(lsp) = self
901            .windows
902            .get_mut(&__active_id)
903            .and_then(|w| w.lsp.as_mut())
904        {
905            for sh in lsp.get_handles_mut(language) {
906                if sh.name == server_name {
907                    for uri in &uris {
908                        tracing::info!(
909                            "Sending didClose for {} to '{}' (language: {})",
910                            uri.as_str(),
911                            sh.name,
912                            language
913                        );
914                        if let Err(e) = sh.handle.did_close(uri.as_uri().clone()) {
915                            tracing::warn!("Failed to send didClose to '{}': {}", sh.name, e);
916                        }
917                    }
918                    break;
919                }
920            }
921        }
922    }
923
924    /// Core server-stop teardown shared by the command-palette and
925    /// status-popup stop paths.
926    ///
927    /// Does the three things that must travel together, in the right
928    /// order:
929    ///
930    /// 1. Shutdown the manager handle(s) — either a single named server
931    ///    or every server configured for `language` (`server_name = None`).
932    /// 2. Clear the matching `lsp_server_statuses` entries on the editor
933    ///    so the status-bar indicator (`compose_lsp_status` in
934    ///    `app/render.rs`) doesn't stay stuck at `"LSP (on)"` with a
935    ///    stale `Running` entry. This is the step the palette path
936    ///    used to miss, producing the user-reported stale-indicator
937    ///    bug.
938    /// 3. Drop diagnostics published by the stopped server(s) so
939    ///    red/yellow overlays don't persist on-screen after the
940    ///    producer is gone.
941    ///
942    /// `didClose` for open buffers is the caller's responsibility and
943    /// MUST happen before this function: the handles are removed as
944    /// part of step 1. The palette caller layers config updates
945    /// (`auto_start = false`) and a user-facing status message on top.
946    ///
947    /// Returns `true` if anything was actually stopped (matches
948    /// `LspManager::shutdown_server`'s contract).
949    pub(crate) fn stop_lsp_server_and_cleanup(
950        &mut self,
951        language: &str,
952        server_name: Option<&str>,
953    ) -> bool {
954        // Snapshot the server names we're about to drop — once the
955        // handles are gone the manager can't enumerate them anymore,
956        // and we need the names for the status + diagnostic cleanup.
957        let stopping_names: Vec<String> = if let Some(name) = server_name {
958            vec![name.to_string()]
959        } else {
960            self.lsp()
961                .map(|lsp| lsp.server_names_for_language(language))
962                .unwrap_or_default()
963        };
964
965        let __active_id = self.active_window;
966
967        let stopped = if let Some(lsp) = self
968            .windows
969            .get_mut(&__active_id)
970            .and_then(|w| w.lsp.as_mut())
971        {
972            if let Some(name) = server_name {
973                lsp.shutdown_server_by_name(language, name)
974            } else {
975                lsp.shutdown_server(language)
976            }
977        } else {
978            false
979        };
980
981        if !stopped {
982            return false;
983        }
984
985        for name in &stopping_names {
986            self.active_window_mut()
987                .lsp_server_statuses
988                .remove(&(language.to_string(), name.clone()));
989            // Clear diagnostics this server published so overlays clear
990            // from every buffer it touched (not just the active one).
991            self.clear_diagnostics_for_server(name);
992        }
993
994        // Clear any in-flight `$/progress` entries for this language
995        // if the language has no surviving handles. The dead server
996        // will never send the matching `end` notifications, so
997        // without this cleanup `compose_lsp_status` would keep
998        // winning the spinner branch over `(off)` — the indicator
999        // would stay stuck on a rotating braille glyph that doesn't
1000        // actually rotate (no async events fire to re-render).
1001        //
1002        // We defer the check to after the shutdown so handle
1003        // enumeration reflects the new state. Keyed by language
1004        // because `LspProgressInfo` doesn't carry a server name —
1005        // safe: if any handle for the language survives, progress
1006        // on that language is still the surviving server's business
1007        // and we leave it alone.
1008        let any_handle_left = self
1009            .lsp()
1010            .as_ref()
1011            .is_some_and(|lsp| lsp.has_handles(language));
1012        if !any_handle_left {
1013            self.active_window_mut()
1014                .lsp_progress
1015                .retain(|_, info| info.language != language);
1016        }
1017
1018        true
1019    }
1020
1021    /// Disable LSP for a specific buffer and clear all LSP-related data
1022    pub(crate) fn disable_lsp_for_buffer(&mut self, buffer_id: crate::model::event::BufferId) {
1023        // Send didClose to the LSP server so it removes the document from its
1024        // tracking. This is critical: without didClose, the async handler's
1025        // document_versions still has the path, and should_skip_did_open will
1026        // block the didOpen when LSP is re-enabled — causing a desync where
1027        // the server has stale content. (GitHub issue #952)
1028        if let Some(uri) = self
1029            .active_window()
1030            .buffer_metadata
1031            .get(&buffer_id)
1032            .and_then(|m| m.file_uri())
1033            .cloned()
1034        {
1035            let language = self
1036                .buffers()
1037                .get(&buffer_id)
1038                .map(|s| s.language.clone())
1039                .unwrap_or_default();
1040            let __active_id = self.active_window;
1041            if let Some(lsp) = self
1042                .windows
1043                .get_mut(&__active_id)
1044                .and_then(|w| w.lsp.as_mut())
1045            {
1046                // Broadcast didClose to all handles for this language
1047                if !lsp.has_handles(&language) {
1048                    tracing::warn!(
1049                        "disable_lsp_for_buffer: no handle for language '{}'",
1050                        language
1051                    );
1052                } else {
1053                    for sh in lsp.get_handles_mut(&language) {
1054                        tracing::info!(
1055                            "Sending didClose for {} to '{}' (language: {})",
1056                            uri.as_str(),
1057                            sh.name,
1058                            language
1059                        );
1060                        if let Err(e) = sh.handle.did_close(uri.as_uri().clone()) {
1061                            tracing::warn!("Failed to send didClose to '{}': {}", sh.name, e);
1062                        }
1063                    }
1064                }
1065            } else {
1066                tracing::warn!("disable_lsp_for_buffer: no LSP manager");
1067            }
1068        } else {
1069            tracing::warn!("disable_lsp_for_buffer: no URI for buffer");
1070        }
1071
1072        // Disable LSP in metadata
1073        if let Some(metadata) = self.active_window_mut().buffer_metadata.get_mut(&buffer_id) {
1074            metadata.disable_lsp(t!("lsp.disabled.user").to_string());
1075            // Clear LSP opened tracking so it will be sent again if re-enabled
1076            metadata.lsp_opened_with.clear();
1077        }
1078        self.set_status_message(t!("lsp.disabled_for_buffer").to_string());
1079
1080        // Clear diagnostics for this buffer
1081        let uri = self
1082            .active_window()
1083            .buffer_metadata
1084            .get(&buffer_id)
1085            .and_then(|m| m.file_uri())
1086            .map(|u| u.as_str().to_string());
1087
1088        if let Some(uri_str) = uri {
1089            self.stored_diagnostics_mut().remove(&uri_str);
1090            self.active_window_mut()
1091                .stored_push_diagnostics
1092                .remove(&uri_str);
1093            self.active_window_mut()
1094                .stored_pull_diagnostics
1095                .remove(&uri_str);
1096            self.active_window_mut()
1097                .diagnostic_result_ids
1098                .remove(&uri_str);
1099            self.stored_folding_ranges_mut().remove(&uri_str);
1100        }
1101
1102        // Cancel scheduled diagnostic pull if it targets this buffer
1103        if let Some((scheduled_buf, _)) = &self.active_window().scheduled_diagnostic_pull {
1104            if *scheduled_buf == buffer_id {
1105                self.active_window_mut().scheduled_diagnostic_pull = None;
1106            }
1107        }
1108
1109        // Cancel scheduled inlay hints refresh if it targets this buffer
1110        if let Some((scheduled_buf, _)) = &self.active_window().scheduled_inlay_hints_request {
1111            if *scheduled_buf == buffer_id {
1112                self.active_window_mut().scheduled_inlay_hints_request = None;
1113            }
1114        }
1115
1116        self.active_window_mut()
1117            .folding_ranges_in_flight
1118            .remove(&buffer_id);
1119        self.active_window_mut()
1120            .folding_ranges_debounce
1121            .remove(&buffer_id);
1122        self.active_window_mut()
1123            .pending_folding_range_requests
1124            .retain(|_, req| req.buffer_id != buffer_id);
1125        // Drop any in-flight inlay hint requests for this buffer so
1126        // their eventual responses don't repopulate the cleared overlay.
1127        self.active_window_mut()
1128            .pending_inlay_hints_requests
1129            .retain(|_, req| req.buffer_id != buffer_id);
1130
1131        // Clear all LSP-related overlays for this buffer (diagnostics + inlay hints)
1132        let diagnostic_ns = crate::services::lsp::diagnostics::lsp_diagnostic_namespace();
1133        self.active_window_mut()
1134            .clear_lsp_overlays_for_buffer(buffer_id, &diagnostic_ns);
1135    }
1136
1137    /// Enable LSP for a specific buffer and send didOpen notification
1138    fn enable_lsp_for_buffer(
1139        &mut self,
1140        buffer_id: crate::model::event::BufferId,
1141        language: &str,
1142        file_path: Option<std::path::PathBuf>,
1143    ) {
1144        // Re-enable LSP in metadata
1145        if let Some(metadata) = self.active_window_mut().buffer_metadata.get_mut(&buffer_id) {
1146            metadata.lsp_enabled = true;
1147            metadata.lsp_disabled_reason = None;
1148        }
1149        self.set_status_message(t!("lsp.enabled_for_buffer").to_string());
1150
1151        // Send didOpen if we have a file path
1152        if let Some(_path) = file_path {
1153            self.send_lsp_did_open_for_buffer(buffer_id, language);
1154        }
1155    }
1156
1157    /// Send LSP didOpen notification for a buffer
1158    fn send_lsp_did_open_for_buffer(
1159        &mut self,
1160        buffer_id: crate::model::event::BufferId,
1161        language: &str,
1162    ) {
1163        // Get the URI and buffer text
1164        let (uri, text) = {
1165            let metadata = self.active_window().buffer_metadata.get(&buffer_id);
1166            let uri = metadata.and_then(|m| m.file_uri()).cloned();
1167            let text = self
1168                .buffers()
1169                .get(&buffer_id)
1170                .and_then(|state| state.buffer.to_string());
1171            (uri, text)
1172        };
1173
1174        let Some(uri) = uri else { return };
1175        let Some(text) = text else { return };
1176
1177        // Try to spawn and send didOpen
1178        use crate::services::lsp::manager::LspSpawnResult;
1179        let file_path = self
1180            .active_window()
1181            .buffer_metadata
1182            .get(&buffer_id)
1183            .and_then(|m| m.file_path())
1184            .cloned();
1185        // Pre-collect buffer info needed later (line/char/version) so
1186        // we don't have to read self.buffers() while holding the
1187        // &mut self.windows borrow on lsp.
1188        let inlay_buffer_info: Option<(u32, u32, u64)> = self
1189            .windows
1190            .get(&self.active_window)
1191            .and_then(|w| w.buffers.get(&buffer_id))
1192            .map(|state| {
1193                let line_count = state.buffer.line_count().unwrap_or(1000);
1194                (
1195                    line_count.saturating_sub(1) as u32,
1196                    10000u32,
1197                    state.buffer.version(),
1198                )
1199            });
1200        let __active_id = self.active_window;
1201        let enable_inlay_hints = self.config.editor.enable_inlay_hints;
1202
1203        let Some(__win) = self.windows.get_mut(&__active_id) else {
1204            return;
1205        };
1206        let diagnostic_result_ids = &__win.diagnostic_result_ids;
1207        let __next_id = &mut __win.next_lsp_request_id;
1208        let buffer_metadata = &mut __win.buffer_metadata;
1209        let Some(lsp) = __win.lsp.as_mut() else {
1210            return;
1211        };
1212
1213        if lsp.try_spawn(language, file_path.as_deref()) != LspSpawnResult::Spawned {
1214            return;
1215        }
1216
1217        let Some(handle) = lsp.get_handle_mut(language) else {
1218            return;
1219        };
1220
1221        let handle_id = handle.id();
1222        if let Err(e) = handle.did_open(uri.as_uri().clone(), text, language.to_string()) {
1223            tracing::warn!("Failed to send didOpen to LSP: {}", e);
1224            return;
1225        }
1226
1227        // Mark buffer as opened with this server
1228        if let Some(metadata) = buffer_metadata.get_mut(&buffer_id) {
1229            metadata.lsp_opened_with.insert(handle_id);
1230        }
1231
1232        // Request diagnostics
1233        let request_id = {
1234            let id = *__next_id;
1235            *__next_id += 1;
1236            id
1237        };
1238        let previous_result_id = diagnostic_result_ids.get(uri.as_str()).cloned();
1239        if let Err(e) =
1240            handle.document_diagnostic(request_id, uri.as_uri().clone(), previous_result_id)
1241        {
1242            tracing::warn!("LSP document_diagnostic request failed: {}", e);
1243        }
1244
1245        // Request inlay hints if enabled
1246        if enable_inlay_hints {
1247            let (last_line, last_char, buffer_version) =
1248                inlay_buffer_info.unwrap_or((999, 10000, 0));
1249
1250            let request_id = {
1251                let id = *__next_id;
1252                *__next_id += 1;
1253                id
1254            };
1255            if let Err(e) =
1256                handle.inlay_hints(request_id, uri.as_uri().clone(), 0, 0, last_line, last_char)
1257            {
1258                tracing::warn!("LSP inlay_hints request failed: {}", e);
1259            } else {
1260                __win.pending_inlay_hints_requests.insert(
1261                    request_id,
1262                    super::InlayHintsRequest {
1263                        buffer_id,
1264                        version: buffer_version,
1265                    },
1266                );
1267            }
1268        }
1269
1270        // Schedule folding range refresh
1271        let _ = __next_id;
1272        let _ = lsp;
1273        let _ = handle;
1274        self.active_window_mut()
1275            .schedule_folding_ranges_refresh(buffer_id);
1276    }
1277
1278    /// Set up a plugin development workspace for LSP support on a buffer.
1279    ///
1280    /// Creates a temp directory with `fresh.d.ts` + `tsconfig.json` so that
1281    /// `typescript-language-server` can provide autocomplete and type checking
1282    /// for plugin buffers (including unsaved/unnamed ones).
1283    pub(crate) fn setup_plugin_dev_lsp(&mut self, buffer_id: BufferId, content: &str) {
1284        use crate::services::plugins::plugin_dev_workspace::PluginDevWorkspace;
1285
1286        // Use the exact cached extraction location for fresh.d.ts
1287        #[cfg(feature = "embed-plugins")]
1288        let fresh_dts_path = {
1289            let Some(embedded_dir) = crate::services::plugins::embedded::get_embedded_plugins_dir()
1290            else {
1291                tracing::warn!(
1292                    "Cannot set up plugin dev LSP: embedded plugins directory not available"
1293                );
1294                return;
1295            };
1296            let path = embedded_dir.join("lib").join("fresh.d.ts");
1297            if !path.exists() {
1298                tracing::warn!(
1299                    "Cannot set up plugin dev LSP: fresh.d.ts not found at {:?}",
1300                    path
1301                );
1302                return;
1303            }
1304            path
1305        };
1306
1307        #[cfg(not(feature = "embed-plugins"))]
1308        let fresh_dts_path = {
1309            // In non-embedded builds (development), use the source tree path
1310            let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1311                .join("plugins")
1312                .join("lib")
1313                .join("fresh.d.ts");
1314            if !path.exists() {
1315                tracing::warn!(
1316                    "Cannot set up plugin dev LSP: fresh.d.ts not found at {:?}",
1317                    path
1318                );
1319                return;
1320            }
1321            path
1322        };
1323
1324        // Create the workspace
1325        let buffer_id_num: usize = buffer_id.0;
1326        match PluginDevWorkspace::create(buffer_id_num, content, &fresh_dts_path) {
1327            Ok(workspace) => {
1328                let plugin_file = workspace.plugin_file.clone();
1329
1330                // Update buffer metadata to point at the temp file, enabling LSP
1331                let plugin_file_uri = super::types::LspUri::from_host_path(
1332                    &plugin_file,
1333                    self.authority.path_translation.as_ref(),
1334                );
1335                if let Some(uri) = plugin_file_uri {
1336                    if let Some(metadata) =
1337                        self.active_window_mut().buffer_metadata.get_mut(&buffer_id)
1338                    {
1339                        metadata.kind = super::types::BufferKind::File {
1340                            path: plugin_file.clone(),
1341                            uri: Some(uri),
1342                        };
1343                        metadata.lsp_enabled = true;
1344                        metadata.lsp_disabled_reason = None;
1345                        // Clear any previous LSP opened state so didOpen is sent fresh
1346                        metadata.lsp_opened_with.clear();
1347
1348                        tracing::info!(
1349                            "Plugin dev LSP enabled for buffer {} via {:?}",
1350                            buffer_id_num,
1351                            plugin_file
1352                        );
1353                    }
1354                }
1355
1356                // Set buffer language to TypeScript so LSP requests use the right handle
1357                if let Some(state) = self
1358                    .windows
1359                    .get_mut(&self.active_window)
1360                    .map(|w| &mut w.buffers)
1361                    .expect("active window present")
1362                    .get_mut(&buffer_id)
1363                {
1364                    let first_line = state.buffer.first_line_lossy();
1365                    let detected =
1366                        crate::primitives::detected_language::DetectedLanguage::from_path(
1367                            &plugin_file,
1368                            first_line.as_deref(),
1369                            &self.grammar_registry,
1370                            &self.config.languages,
1371                        );
1372                    state.apply_language(detected);
1373                }
1374
1375                // Allow TypeScript language so LSP auto-spawns
1376                let __active_id = self.active_window;
1377                if let Some(lsp) = self
1378                    .windows
1379                    .get_mut(&__active_id)
1380                    .and_then(|w| w.lsp.as_mut())
1381                {
1382                    lsp.allow_language("typescript");
1383                }
1384
1385                // Store workspace for cleanup
1386                let workspace_dir = workspace.dir().to_path_buf();
1387                self.active_window_mut()
1388                    .plugin_dev_workspaces
1389                    .insert(buffer_id, workspace);
1390
1391                // Actually spawn the LSP server and send didOpen for this buffer
1392                self.send_lsp_did_open_for_buffer(buffer_id, "typescript");
1393
1394                // Add the plugin workspace folder so tsserver discovers tsconfig.json + fresh.d.ts
1395                if let Some(lsp) = self.lsp() {
1396                    if let Some(handle) = lsp.get_handle("typescript") {
1397                        if let Some(uri) = super::types::file_path_to_lsp_uri_with_translation(
1398                            &workspace_dir,
1399                            self.authority.path_translation.as_ref(),
1400                        ) {
1401                            let name = workspace_dir
1402                                .file_name()
1403                                .unwrap_or_default()
1404                                .to_string_lossy()
1405                                .into_owned();
1406                            if let Err(e) = handle.add_workspace_folder(uri, name) {
1407                                tracing::warn!("Failed to add plugin workspace folder: {}", e);
1408                            } else {
1409                                tracing::info!(
1410                                    "Added plugin workspace folder: {:?}",
1411                                    workspace_dir
1412                                );
1413                            }
1414                        }
1415                    }
1416                }
1417            }
1418            Err(e) => {
1419                tracing::warn!("Failed to create plugin dev workspace: {}", e);
1420            }
1421        }
1422    }
1423}