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