Skip to main content

fresh/app/
lsp_actions.rs

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