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