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    /// Restarts the LSP server for the current buffer's language and re-sends
16    /// didOpen notifications for all buffers of that language.
17    pub fn handle_lsp_restart(&mut self) {
18        // Get the language from the buffer's stored state
19        let buffer_id = self.active_buffer();
20        let Some(state) = self.buffers.get(&buffer_id) else {
21            return;
22        };
23        let language = state.language.clone();
24
25        // Check if LSP is configured for this language before attempting restart
26        let lsp_configured = self
27            .lsp
28            .as_ref()
29            .and_then(|lsp| lsp.get_config(&language))
30            .is_some();
31
32        if !lsp_configured {
33            self.set_status_message(t!("lsp.no_server_configured").to_string());
34            return;
35        }
36
37        // Attempt restart
38        let Some(lsp) = self.lsp.as_mut() else {
39            self.set_status_message(t!("lsp.no_manager").to_string());
40            return;
41        };
42
43        let (success, message) = lsp.manual_restart(&language);
44        self.status_message = Some(message);
45
46        if !success {
47            return;
48        }
49
50        // Re-send didOpen for all buffers of this language
51        self.reopen_buffers_for_language(&language);
52    }
53
54    /// Re-send didOpen notifications for all buffers of a given language.
55    ///
56    /// Called after LSP server restart to re-register open files.
57    pub(crate) fn reopen_buffers_for_language(&mut self, language: &str) {
58        // Collect buffer info first to avoid borrow conflicts
59        // Use buffer's stored language rather than detecting from path
60        let buffers_for_language: Vec<_> = self
61            .buffers
62            .iter()
63            .filter_map(|(buf_id, state)| {
64                if state.language == language {
65                    self.buffer_metadata
66                        .get(buf_id)
67                        .and_then(|meta| meta.file_path().map(|p| (*buf_id, p.clone())))
68                } else {
69                    None
70                }
71            })
72            .collect();
73
74        for (buffer_id, buf_path) in buffers_for_language {
75            let Some(state) = self.buffers.get(&buffer_id) else {
76                continue;
77            };
78
79            let Some(content) = state.buffer.to_string() else {
80                continue; // Skip buffers that aren't fully loaded
81            };
82
83            let Some(uri) = super::types::file_path_to_lsp_uri(&buf_path) else {
84                continue;
85            };
86
87            let lang_id = state.language.clone();
88
89            if let Some(lsp) = self.lsp.as_mut() {
90                // Respect auto_start setting for this user action
91                use crate::services::lsp::manager::LspSpawnResult;
92                if lsp.try_spawn(&lang_id) == LspSpawnResult::Spawned {
93                    if let Some(handle) = lsp.get_handle_mut(&lang_id) {
94                        let handle_id = handle.id();
95                        if let Err(e) = handle.did_open(uri, content, lang_id) {
96                            tracing::warn!("LSP did_open failed: {}", e);
97                        } else {
98                            // Mark buffer as opened with this handle so that
99                            // send_lsp_changes_for_buffer doesn't re-send didOpen
100                            if let Some(metadata) = self.buffer_metadata.get_mut(&buffer_id) {
101                                metadata.lsp_opened_with.insert(handle_id);
102                            }
103                        }
104                    }
105                }
106            }
107        }
108    }
109
110    /// Handle the LspStop action.
111    ///
112    /// Shows a prompt to select which LSP server to stop, with suggestions
113    /// for all currently running servers.
114    pub fn handle_lsp_stop(&mut self) {
115        let running_servers: Vec<String> = self
116            .lsp
117            .as_ref()
118            .map(|lsp| lsp.running_servers())
119            .unwrap_or_default();
120
121        if running_servers.is_empty() {
122            self.set_status_message(t!("lsp.no_servers_running").to_string());
123            return;
124        }
125
126        // Create suggestions from running servers
127        let suggestions: Vec<Suggestion> = running_servers
128            .iter()
129            .map(|lang| {
130                let description = self
131                    .lsp
132                    .as_ref()
133                    .and_then(|lsp| lsp.get_config(lang))
134                    .filter(|c| !c.command.is_empty())
135                    .map(|c| format!("Command: {}", c.command));
136
137                Suggestion {
138                    text: lang.clone(),
139                    description,
140                    value: Some(lang.clone()),
141                    disabled: false,
142                    keybinding: None,
143                    source: None,
144                }
145            })
146            .collect();
147
148        // Start prompt with suggestions
149        self.prompt = Some(Prompt::with_suggestions(
150            "Stop LSP server: ".to_string(),
151            PromptType::StopLspServer,
152            suggestions,
153        ));
154
155        // Configure initial selection
156        if let Some(prompt) = self.prompt.as_mut() {
157            if running_servers.len() == 1 {
158                // If only one server, pre-fill the input with it
159                prompt.input = running_servers[0].clone();
160                prompt.cursor_pos = prompt.input.len();
161                prompt.selected_suggestion = Some(0);
162            } else if !prompt.suggestions.is_empty() {
163                // Auto-select first suggestion
164                prompt.selected_suggestion = Some(0);
165            }
166        }
167    }
168
169    /// Handle the LspToggleForBuffer action.
170    ///
171    /// Toggles LSP on/off for the current buffer only.
172    /// Requires an LSP server to be configured for the current buffer's language.
173    pub fn handle_lsp_toggle_for_buffer(&mut self) {
174        let buffer_id = self.active_buffer();
175
176        // Get the buffer's language to check if LSP is configured
177        let language = {
178            let Some(state) = self.buffers.get(&buffer_id) else {
179                return;
180            };
181            state.language.clone()
182        };
183
184        // Check if LSP is configured for this language
185        let lsp_configured = self
186            .lsp
187            .as_ref()
188            .and_then(|lsp| lsp.get_config(&language))
189            .is_some();
190
191        if !lsp_configured {
192            self.set_status_message(t!("lsp.no_server_configured").to_string());
193            return;
194        }
195
196        // Check current LSP state
197        let (was_enabled, file_path) = {
198            let Some(metadata) = self.buffer_metadata.get(&buffer_id) else {
199                return;
200            };
201            (metadata.lsp_enabled, metadata.file_path().cloned())
202        };
203
204        if was_enabled {
205            self.disable_lsp_for_buffer(buffer_id);
206        } else {
207            self.enable_lsp_for_buffer(buffer_id, &language, file_path);
208        }
209    }
210
211    /// Toggle folding at the current cursor position.
212    pub fn toggle_fold_at_cursor(&mut self) {
213        let buffer_id = self.active_buffer();
214        let pos = self.active_cursors().primary().position;
215        self.toggle_fold_at_byte(buffer_id, pos);
216    }
217
218    /// Toggle folding for the given line in the specified buffer.
219    ///
220    /// Kept for callers that only have a line number (e.g. gutter clicks
221    /// that already resolved the line).  Converts to a byte position and
222    /// delegates to [`Self::toggle_fold_at_byte`].
223    pub fn toggle_fold_at_line(&mut self, buffer_id: BufferId, line: usize) {
224        let byte_pos = {
225            let Some(state) = self.buffers.get(&buffer_id) else {
226                return;
227            };
228            state.buffer.line_start_offset(line).unwrap_or_else(|| {
229                use crate::view::folding::indent_folding;
230                let approx = line * state.buffer.estimated_line_length();
231                indent_folding::find_line_start_byte(&state.buffer, approx)
232            })
233        };
234        self.toggle_fold_at_byte(buffer_id, byte_pos);
235    }
236
237    /// Toggle folding at the given byte position in the specified buffer.
238    pub fn toggle_fold_at_byte(&mut self, buffer_id: BufferId, byte_pos: usize) {
239        let split_id = self.split_manager.active_split();
240        let (buffers, split_view_states) = (&mut self.buffers, &mut self.split_view_states);
241
242        let Some(state) = buffers.get_mut(&buffer_id) else {
243            return;
244        };
245
246        let Some(view_state) = split_view_states.get_mut(&split_id) else {
247            return;
248        };
249        let buf_state = view_state.ensure_buffer_state(buffer_id);
250
251        // Try to unfold first — check if this byte's line is a fold header.
252        let header_byte = {
253            use crate::view::folding::indent_folding;
254            indent_folding::find_line_start_byte(&state.buffer, byte_pos)
255        };
256        if buf_state
257            .folds
258            .remove_by_header_byte(&state.buffer, &mut state.marker_list, header_byte)
259        {
260            return;
261        }
262
263        // Also unfold if the byte position is inside an existing fold.
264        if buf_state
265            .folds
266            .remove_if_contains_byte(&mut state.marker_list, byte_pos)
267        {
268            return;
269        }
270
271        // Determine the fold byte range: prefer LSP ranges, fall back to indent-based.
272        if !state.folding_ranges.is_empty() {
273            // --- LSP-provided ranges (line-based) ---
274            // LSP ranges use line numbers, so we need get_line_number here.
275            let line = state.buffer.get_line_number(byte_pos);
276            let mut exact_range: Option<&lsp_types::FoldingRange> = None;
277            let mut exact_span = usize::MAX;
278            let mut containing_range: Option<&lsp_types::FoldingRange> = None;
279            let mut containing_span = usize::MAX;
280
281            for range in &state.folding_ranges {
282                let start_line = range.start_line as usize;
283                let range_end = range.end_line as usize;
284                if range_end <= start_line {
285                    continue;
286                }
287                let span = range_end.saturating_sub(start_line);
288
289                if start_line == line && span < exact_span {
290                    exact_span = span;
291                    exact_range = Some(range);
292                }
293                if start_line <= line && line <= range_end && span < containing_span {
294                    containing_span = span;
295                    containing_range = Some(range);
296                }
297            }
298
299            let chosen = exact_range.or(containing_range);
300            let Some(range) = chosen else {
301                return;
302            };
303            let placeholder = range
304                .collapsed_text
305                .as_ref()
306                .filter(|text| !text.trim().is_empty())
307                .cloned();
308            let header_line = range.start_line as usize;
309            let end_line = range.end_line as usize;
310            let first_hidden = header_line.saturating_add(1);
311            if first_hidden > end_line {
312                return;
313            }
314            let Some(sb) = state.buffer.line_start_offset(first_hidden) else {
315                return;
316            };
317            let eb = state
318                .buffer
319                .line_start_offset(end_line.saturating_add(1))
320                .unwrap_or_else(|| state.buffer.len());
321            let hb = state.buffer.line_start_offset(header_line).unwrap_or(0);
322            Self::create_fold(state, buf_state, sb, eb, hb, placeholder);
323        } else {
324            // --- Indent-based folding on bytes ---
325            use crate::view::folding::indent_folding;
326            let tab_size = state.buffer_settings.tab_size;
327            let max_upward = crate::config::INDENT_FOLD_MAX_UPWARD_SCAN;
328            let est_ll = state.buffer.estimated_line_length();
329            let max_scan_bytes = crate::config::INDENT_FOLD_MAX_SCAN_LINES * est_ll;
330
331            // Ensure the region around the cursor is loaded from disk so the
332            // immutable slice_bytes in find_fold_range_at_byte can read it.
333            let upward_bytes = max_upward * est_ll;
334            let load_start = byte_pos.saturating_sub(upward_bytes);
335            let load_end = byte_pos
336                .saturating_add(max_scan_bytes)
337                .min(state.buffer.len());
338            // Load chunks from disk so immutable slice_bytes in
339            // find_fold_range_at_byte can read the region.
340            drop(
341                state
342                    .buffer
343                    .get_text_range_mut(load_start, load_end - load_start),
344            );
345
346            if let Some((hb, sb, eb)) = indent_folding::find_fold_range_at_byte(
347                &state.buffer,
348                byte_pos,
349                tab_size,
350                max_scan_bytes,
351                max_upward,
352            ) {
353                Self::create_fold(state, buf_state, sb, eb, hb, None);
354            }
355        }
356    }
357
358    fn create_fold(
359        state: &mut crate::state::EditorState,
360        buf_state: &mut crate::view::split::BufferViewState,
361        start_byte: usize,
362        end_byte: usize,
363        header_byte: usize,
364        placeholder: Option<String>,
365    ) {
366        if end_byte <= start_byte {
367            return;
368        }
369
370        // Move any cursors inside the soon-to-be-hidden range to the header line.
371        buf_state.cursors.map(|cursor| {
372            let in_hidden_range = cursor.position >= start_byte && cursor.position < end_byte;
373            let anchor_in_hidden = cursor
374                .anchor
375                .is_some_and(|anchor| anchor >= start_byte && anchor < end_byte);
376            if in_hidden_range || anchor_in_hidden {
377                cursor.position = header_byte;
378                cursor.anchor = None;
379                cursor.sticky_column = 0;
380                cursor.selection_mode = crate::model::cursor::SelectionMode::Normal;
381                cursor.block_anchor = None;
382                cursor.deselect_on_move = true;
383            }
384        });
385
386        buf_state
387            .folds
388            .add(&mut state.marker_list, start_byte, end_byte, placeholder);
389
390        // If the viewport top is now inside the folded range, move it to the header.
391        if buf_state.viewport.top_byte >= start_byte && buf_state.viewport.top_byte < end_byte {
392            buf_state.viewport.top_byte = header_byte;
393            buf_state.viewport.top_view_line_offset = 0;
394        }
395    }
396
397    /// Disable LSP for a specific buffer and clear all LSP-related data
398    pub(crate) fn disable_lsp_for_buffer(&mut self, buffer_id: crate::model::event::BufferId) {
399        // Send didClose to the LSP server so it removes the document from its
400        // tracking. This is critical: without didClose, the async handler's
401        // document_versions still has the path, and should_skip_did_open will
402        // block the didOpen when LSP is re-enabled — causing a desync where
403        // the server has stale content. (GitHub issue #952)
404        if let Some(uri) = self
405            .buffer_metadata
406            .get(&buffer_id)
407            .and_then(|m| m.file_uri())
408            .cloned()
409        {
410            let language = self
411                .buffers
412                .get(&buffer_id)
413                .map(|s| s.language.clone())
414                .unwrap_or_default();
415            if let Some(lsp) = self.lsp.as_mut() {
416                if let Some(handle) = lsp.get_handle_mut(&language) {
417                    tracing::info!(
418                        "Sending didClose for {} (language: {})",
419                        uri.as_str(),
420                        language
421                    );
422                    if let Err(e) = handle.did_close(uri) {
423                        tracing::warn!("Failed to send didClose to LSP: {}", e);
424                    }
425                } else {
426                    tracing::warn!(
427                        "disable_lsp_for_buffer: no handle for language '{}'",
428                        language
429                    );
430                }
431            } else {
432                tracing::warn!("disable_lsp_for_buffer: no LSP manager");
433            }
434        } else {
435            tracing::warn!("disable_lsp_for_buffer: no URI for buffer");
436        }
437
438        // Disable LSP in metadata
439        if let Some(metadata) = self.buffer_metadata.get_mut(&buffer_id) {
440            metadata.disable_lsp(t!("lsp.disabled.user").to_string());
441            // Clear LSP opened tracking so it will be sent again if re-enabled
442            metadata.lsp_opened_with.clear();
443        }
444        self.set_status_message(t!("lsp.disabled_for_buffer").to_string());
445
446        // Clear diagnostics for this buffer
447        let uri = self
448            .buffer_metadata
449            .get(&buffer_id)
450            .and_then(|m| m.file_uri())
451            .map(|u| u.as_str().to_string());
452
453        if let Some(uri_str) = uri {
454            self.stored_diagnostics.remove(&uri_str);
455            self.stored_push_diagnostics.remove(&uri_str);
456            self.stored_pull_diagnostics.remove(&uri_str);
457            self.diagnostic_result_ids.remove(&uri_str);
458            self.stored_folding_ranges.remove(&uri_str);
459        }
460
461        // Cancel scheduled diagnostic pull if it targets this buffer
462        if let Some((scheduled_buf, _)) = &self.scheduled_diagnostic_pull {
463            if *scheduled_buf == buffer_id {
464                self.scheduled_diagnostic_pull = None;
465            }
466        }
467
468        self.folding_ranges_in_flight.remove(&buffer_id);
469        self.folding_ranges_debounce.remove(&buffer_id);
470        self.pending_folding_range_requests
471            .retain(|_, req| req.buffer_id != buffer_id);
472
473        // Clear all LSP-related overlays for this buffer (diagnostics + inlay hints)
474        let diagnostic_ns = crate::services::lsp::diagnostics::lsp_diagnostic_namespace();
475        let (buffers, split_view_states) = (&mut self.buffers, &mut self.split_view_states);
476        if let Some(state) = buffers.get_mut(&buffer_id) {
477            state
478                .overlays
479                .clear_namespace(&diagnostic_ns, &mut state.marker_list);
480            state.virtual_texts.clear(&mut state.marker_list);
481            state.folding_ranges.clear();
482            for view_state in split_view_states.values_mut() {
483                if let Some(buf_state) = view_state.keyed_states.get_mut(&buffer_id) {
484                    buf_state.folds.clear(&mut state.marker_list);
485                }
486            }
487        }
488    }
489
490    /// Enable LSP for a specific buffer and send didOpen notification
491    fn enable_lsp_for_buffer(
492        &mut self,
493        buffer_id: crate::model::event::BufferId,
494        language: &str,
495        file_path: Option<std::path::PathBuf>,
496    ) {
497        // Re-enable LSP in metadata
498        if let Some(metadata) = self.buffer_metadata.get_mut(&buffer_id) {
499            metadata.lsp_enabled = true;
500            metadata.lsp_disabled_reason = None;
501        }
502        self.set_status_message(t!("lsp.enabled_for_buffer").to_string());
503
504        // Send didOpen if we have a file path
505        if let Some(_path) = file_path {
506            self.send_lsp_did_open_for_buffer(buffer_id, language);
507        }
508    }
509
510    /// Send LSP didOpen notification for a buffer
511    fn send_lsp_did_open_for_buffer(
512        &mut self,
513        buffer_id: crate::model::event::BufferId,
514        language: &str,
515    ) {
516        // Get the URI and buffer text
517        let (uri, text) = {
518            let metadata = self.buffer_metadata.get(&buffer_id);
519            let uri = metadata.and_then(|m| m.file_uri()).cloned();
520            let text = self
521                .buffers
522                .get(&buffer_id)
523                .and_then(|state| state.buffer.to_string());
524            (uri, text)
525        };
526
527        let Some(uri) = uri else { return };
528        let Some(text) = text else { return };
529
530        // Try to spawn and send didOpen
531        use crate::services::lsp::manager::LspSpawnResult;
532        let Some(lsp) = self.lsp.as_mut() else {
533            return;
534        };
535
536        if lsp.try_spawn(language) != LspSpawnResult::Spawned {
537            return;
538        }
539
540        let Some(handle) = lsp.get_handle_mut(language) else {
541            return;
542        };
543
544        let handle_id = handle.id();
545        if let Err(e) = handle.did_open(uri.clone(), text, language.to_string()) {
546            tracing::warn!("Failed to send didOpen to LSP: {}", e);
547            return;
548        }
549
550        // Mark buffer as opened with this server
551        if let Some(metadata) = self.buffer_metadata.get_mut(&buffer_id) {
552            metadata.lsp_opened_with.insert(handle_id);
553        }
554
555        // Request diagnostics
556        let request_id = self.next_lsp_request_id;
557        self.next_lsp_request_id += 1;
558        let previous_result_id = self.diagnostic_result_ids.get(uri.as_str()).cloned();
559        if let Err(e) = handle.document_diagnostic(request_id, uri.clone(), previous_result_id) {
560            tracing::warn!("LSP document_diagnostic request failed: {}", e);
561        }
562
563        // Request inlay hints if enabled
564        if self.config.editor.enable_inlay_hints {
565            let (last_line, last_char) = self
566                .buffers
567                .get(&buffer_id)
568                .map(|state| {
569                    let line_count = state.buffer.line_count().unwrap_or(1000);
570                    (line_count.saturating_sub(1) as u32, 10000u32)
571                })
572                .unwrap_or((999, 10000));
573
574            let request_id = self.next_lsp_request_id;
575            self.next_lsp_request_id += 1;
576            if let Err(e) = handle.inlay_hints(request_id, uri, 0, 0, last_line, last_char) {
577                tracing::warn!("LSP inlay_hints request failed: {}", e);
578            }
579        }
580
581        // Schedule folding range refresh
582        self.schedule_folding_ranges_refresh(buffer_id);
583    }
584
585    /// Set up a plugin development workspace for LSP support on a buffer.
586    ///
587    /// Creates a temp directory with `fresh.d.ts` + `tsconfig.json` so that
588    /// `typescript-language-server` can provide autocomplete and type checking
589    /// for plugin buffers (including unsaved/unnamed ones).
590    pub(crate) fn setup_plugin_dev_lsp(&mut self, buffer_id: BufferId, content: &str) {
591        use crate::services::plugins::plugin_dev_workspace::PluginDevWorkspace;
592
593        // Use the exact cached extraction location for fresh.d.ts
594        #[cfg(feature = "embed-plugins")]
595        let fresh_dts_path = {
596            let Some(embedded_dir) = crate::services::plugins::embedded::get_embedded_plugins_dir()
597            else {
598                tracing::warn!(
599                    "Cannot set up plugin dev LSP: embedded plugins directory not available"
600                );
601                return;
602            };
603            let path = embedded_dir.join("lib").join("fresh.d.ts");
604            if !path.exists() {
605                tracing::warn!(
606                    "Cannot set up plugin dev LSP: fresh.d.ts not found at {:?}",
607                    path
608                );
609                return;
610            }
611            path
612        };
613
614        #[cfg(not(feature = "embed-plugins"))]
615        let fresh_dts_path = {
616            // In non-embedded builds (development), use the source tree path
617            let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
618                .join("plugins")
619                .join("lib")
620                .join("fresh.d.ts");
621            if !path.exists() {
622                tracing::warn!(
623                    "Cannot set up plugin dev LSP: fresh.d.ts not found at {:?}",
624                    path
625                );
626                return;
627            }
628            path
629        };
630
631        // Create the workspace
632        let buffer_id_num: usize = buffer_id.0;
633        match PluginDevWorkspace::create(buffer_id_num, content, &fresh_dts_path) {
634            Ok(workspace) => {
635                let plugin_file = workspace.plugin_file.clone();
636
637                // Update buffer metadata to point at the temp file, enabling LSP
638                if let Some(metadata) = self.buffer_metadata.get_mut(&buffer_id) {
639                    if let Some(uri) = super::types::file_path_to_lsp_uri(&plugin_file) {
640                        metadata.kind = super::types::BufferKind::File {
641                            path: plugin_file.clone(),
642                            uri: Some(uri),
643                        };
644                        metadata.lsp_enabled = true;
645                        metadata.lsp_disabled_reason = None;
646                        // Clear any previous LSP opened state so didOpen is sent fresh
647                        metadata.lsp_opened_with.clear();
648
649                        tracing::info!(
650                            "Plugin dev LSP enabled for buffer {} via {:?}",
651                            buffer_id_num,
652                            plugin_file
653                        );
654                    }
655                }
656
657                // Set buffer language to TypeScript so LSP requests use the right handle
658                if let Some(state) = self.buffers.get_mut(&buffer_id) {
659                    let detected =
660                        crate::primitives::detected_language::DetectedLanguage::from_path(
661                            &plugin_file,
662                            &self.grammar_registry,
663                            &self.config.languages,
664                        );
665                    state.apply_language(detected);
666                }
667
668                // Allow TypeScript language so LSP auto-spawns
669                if let Some(lsp) = &mut self.lsp {
670                    lsp.allow_language("typescript");
671                }
672
673                // Store workspace for cleanup
674                let workspace_dir = workspace.dir().to_path_buf();
675                self.plugin_dev_workspaces.insert(buffer_id, workspace);
676
677                // Actually spawn the LSP server and send didOpen for this buffer
678                self.send_lsp_did_open_for_buffer(buffer_id, "typescript");
679
680                // Add the plugin workspace folder so tsserver discovers tsconfig.json + fresh.d.ts
681                if let Some(lsp) = &self.lsp {
682                    if let Some(handle) = lsp.get_handle("typescript") {
683                        if let Some(uri) = super::types::file_path_to_lsp_uri(&workspace_dir) {
684                            let name = workspace_dir
685                                .file_name()
686                                .unwrap_or_default()
687                                .to_string_lossy()
688                                .into_owned();
689                            if let Err(e) = handle.add_workspace_folder(uri, name) {
690                                tracing::warn!("Failed to add plugin workspace folder: {}", e);
691                            } else {
692                                tracing::info!(
693                                    "Added plugin workspace folder: {:?}",
694                                    workspace_dir
695                                );
696                            }
697                        }
698                    }
699                }
700            }
701            Err(e) => {
702                tracing::warn!("Failed to create plugin dev workspace: {}", e);
703            }
704        }
705    }
706}