Support custom fold text for LSP folds (#48624)

Kirill Bulatov created

Follow-up of https://github.com/zed-industries/zed/pull/48611

Release Notes:

- N/A

Change summary

crates/agent_ui/src/text_thread_editor.rs      |   1 
crates/editor/src/display_map.rs               |  35 +
crates/editor/src/display_map/fold_map.rs      |  55 ++
crates/editor/src/editor.rs                    |   9 
crates/editor/src/folding_ranges.rs            | 412 ++++++++++++++++++++
crates/lsp/src/lsp.rs                          |   2 
crates/project/src/lsp_command.rs              |  67 ++
crates/project/src/lsp_store.rs                |   1 
crates/project/src/lsp_store/folding_ranges.rs |  20 
crates/proto/proto/lsp.proto                   |   1 
10 files changed, 557 insertions(+), 46 deletions(-)

Detailed changes

crates/agent_ui/src/text_thread_editor.rs 🔗

@@ -2999,6 +2999,7 @@ fn invoked_slash_command_fold_placeholder(
     text_thread: WeakEntity<TextThread>,
 ) -> FoldPlaceholder {
     FoldPlaceholder {
+        collapsed_text: None,
         constrain_width: false,
         merge_adjacent: false,
         render: Arc::new(move |fold_id, _, cx| {

crates/editor/src/display_map.rs 🔗

@@ -103,7 +103,7 @@ use multi_buffer::{
     MultiBufferPoint, MultiBufferRow, MultiBufferSnapshot, RowInfo, ToOffset, ToPoint,
 };
 use project::project_settings::DiagnosticSeverity;
-use project::{InlayId, lsp_store::TokenType};
+use project::{InlayId, lsp_store::LspFoldingRange, lsp_store::TokenType};
 use serde::Deserialize;
 use sum_tree::{Bias, TreeMap};
 use text::{BufferId, LineIndent, Patch};
@@ -1342,7 +1342,7 @@ impl DisplayMap {
     pub(super) fn set_lsp_folding_ranges(
         &mut self,
         buffer_id: BufferId,
-        ranges: Vec<Range<text::Anchor>>,
+        ranges: Vec<LspFoldingRange>,
         cx: &mut Context<Self>,
     ) {
         let snapshot = self.buffer.read(cx).snapshot(cx);
@@ -1365,12 +1365,31 @@ impl DisplayMap {
             .map(|(id, _, _)| id)
             .collect::<Vec<_>>();
 
-        let placeholder = self.fold_placeholder.clone();
-        let creases = ranges.into_iter().filter_map(|range| {
-            let mb_range = excerpt_ids
-                .iter()
-                .find_map(|&id| snapshot.anchor_range_in_excerpt(id, range.clone()))?;
-            Some(Crease::simple(mb_range, placeholder.clone()))
+        let base_placeholder = self.fold_placeholder.clone();
+        let creases = ranges.into_iter().filter_map(|folding_range| {
+            let mb_range = excerpt_ids.iter().find_map(|&id| {
+                snapshot.anchor_range_in_excerpt(id, folding_range.range.clone())
+            })?;
+            let placeholder = if let Some(collapsed_text) = folding_range.collapsed_text {
+                FoldPlaceholder {
+                    render: Arc::new({
+                        let collapsed_text = collapsed_text.clone();
+                        move |fold_id, _fold_range, cx: &mut gpui::App| {
+                            use gpui::{Element as _, ParentElement as _};
+                            FoldPlaceholder::fold_element(fold_id, cx)
+                                .child(collapsed_text.clone())
+                                .into_any()
+                        }
+                    }),
+                    constrain_width: false,
+                    merge_adjacent: base_placeholder.merge_adjacent,
+                    type_tag: base_placeholder.type_tag,
+                    collapsed_text: Some(collapsed_text),
+                }
+            } else {
+                base_placeholder.clone()
+            };
+            Some(Crease::simple(mb_range, placeholder))
         });
 
         let new_ids = self.crease_map.insert(creases, &snapshot);

crates/editor/src/display_map/fold_map.rs 🔗

@@ -4,7 +4,7 @@ use super::{
     Highlights,
     inlay_map::{InlayBufferRows, InlayChunks, InlayEdit, InlayOffset, InlayPoint, InlaySnapshot},
 };
-use gpui::{AnyElement, App, ElementId, HighlightStyle, Pixels, Window};
+use gpui::{AnyElement, App, ElementId, HighlightStyle, Pixels, SharedString, Stateful, Window};
 use language::{Edit, HighlightId, Point};
 use multi_buffer::{
     Anchor, AnchorRangeExt, MBTextSummary, MultiBufferOffset, MultiBufferRow, MultiBufferSnapshot,
@@ -33,6 +33,9 @@ pub struct FoldPlaceholder {
     pub merge_adjacent: bool,
     /// Category of the fold. Useful for carefully removing from overlapping folds.
     pub type_tag: Option<TypeId>,
+    /// Text provided by the language server to display in place of the folded range.
+    /// When set, this is used instead of the default "⋯" ellipsis.
+    pub collapsed_text: Option<SharedString>,
 }
 
 impl Default for FoldPlaceholder {
@@ -42,11 +45,27 @@ impl Default for FoldPlaceholder {
             constrain_width: true,
             merge_adjacent: true,
             type_tag: None,
+            collapsed_text: None,
         }
     }
 }
 
 impl FoldPlaceholder {
+    /// Returns a styled `Div` container with the standard fold‐placeholder
+    /// look (background, hover, active, rounded corners, full size).
+    /// Callers add children and event handlers on top.
+    pub fn fold_element(fold_id: FoldId, cx: &App) -> Stateful<gpui::Div> {
+        use gpui::{InteractiveElement as _, StatefulInteractiveElement as _, Styled as _};
+        use theme::ActiveTheme as _;
+        gpui::div()
+            .id(fold_id)
+            .bg(cx.theme().colors().ghost_element_background)
+            .hover(|style| style.bg(cx.theme().colors().ghost_element_hover))
+            .active(|style| style.bg(cx.theme().colors().ghost_element_active))
+            .rounded_xs()
+            .size_full()
+    }
+
     #[cfg(any(test, feature = "test-support"))]
     pub fn test() -> Self {
         Self {
@@ -54,6 +73,7 @@ impl FoldPlaceholder {
             constrain_width: true,
             merge_adjacent: true,
             type_tag: None,
+            collapsed_text: None,
         }
     }
 }
@@ -62,6 +82,7 @@ impl fmt::Debug for FoldPlaceholder {
     fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
         f.debug_struct("FoldPlaceholder")
             .field("constrain_width", &self.constrain_width)
+            .field("collapsed_text", &self.collapsed_text)
             .finish()
     }
 }
@@ -70,7 +91,9 @@ impl Eq for FoldPlaceholder {}
 
 impl PartialEq for FoldPlaceholder {
     fn eq(&self, other: &Self) -> bool {
-        Arc::ptr_eq(&self.render, &other.render) && self.constrain_width == other.constrain_width
+        Arc::ptr_eq(&self.render, &other.render)
+            && self.constrain_width == other.constrain_width
+            && self.collapsed_text == other.collapsed_text
     }
 }
 
@@ -532,17 +555,28 @@ impl FoldMap {
                     if fold_range.end > fold_range.start {
                         const ELLIPSIS: &str = "⋯";
 
+                        let placeholder_text: SharedString = fold
+                            .placeholder
+                            .collapsed_text
+                            .clone()
+                            .unwrap_or_else(|| ELLIPSIS.into());
+                        let chars_bitmap = placeholder_text
+                            .char_indices()
+                            .fold(0u128, |bitmap, (idx, _)| {
+                                bitmap | 1u128.unbounded_shl(idx as u32)
+                            });
+
                         let fold_id = fold.id;
                         new_transforms.push(
                             Transform {
                                 summary: TransformSummary {
-                                    output: MBTextSummary::from(ELLIPSIS),
+                                    output: MBTextSummary::from(placeholder_text.as_ref()),
                                     input: inlay_snapshot
                                         .text_summary_for_range(fold_range.start..fold_range.end),
                                 },
                                 placeholder: Some(TransformPlaceholder {
-                                    text: ELLIPSIS,
-                                    chars: 1,
+                                    text: placeholder_text,
+                                    chars: chars_bitmap,
                                     renderer: ChunkRenderer {
                                         id: ChunkRendererId::Fold(fold.id),
                                         render: Arc::new(move |cx| {
@@ -691,7 +725,7 @@ impl FoldSnapshot {
             let end_in_transform = cmp::min(range.end, cursor.end().0).0 - cursor.start().0.0;
             if let Some(placeholder) = transform.placeholder.as_ref() {
                 summary = MBTextSummary::from(
-                    &placeholder.text
+                    &placeholder.text.as_ref()
                         [start_in_transform.column as usize..end_in_transform.column as usize],
                 );
             } else {
@@ -715,8 +749,9 @@ impl FoldSnapshot {
             if let Some(transform) = cursor.item() {
                 let end_in_transform = range.end.0 - cursor.start().0.0;
                 if let Some(placeholder) = transform.placeholder.as_ref() {
-                    summary +=
-                        MBTextSummary::from(&placeholder.text[..end_in_transform.column as usize]);
+                    summary += MBTextSummary::from(
+                        &placeholder.text.as_ref()[..end_in_transform.column as usize],
+                    );
                 } else {
                     let inlay_start = self.inlay_snapshot.to_offset(cursor.start().1);
                     let inlay_end = self
@@ -1115,7 +1150,7 @@ struct Transform {
 
 #[derive(Clone, Debug)]
 struct TransformPlaceholder {
-    text: &'static str,
+    text: SharedString,
     chars: u128,
     renderer: ChunkRenderer,
 }
@@ -1479,7 +1514,7 @@ impl<'a> Iterator for FoldChunks<'a> {
 
             self.output_offset.0 += placeholder.text.len();
             return Some(Chunk {
-                text: placeholder.text,
+                text: &placeholder.text,
                 chars: placeholder.chars,
                 renderer: Some(placeholder.renderer.clone()),
                 ..Default::default()

crates/editor/src/editor.rs 🔗

@@ -2067,13 +2067,7 @@ impl Editor {
             constrain_width: false,
             render: Arc::new(move |fold_id, fold_range, cx| {
                 let editor = editor.clone();
-                div()
-                    .id(fold_id)
-                    .bg(cx.theme().colors().ghost_element_background)
-                    .hover(|style| style.bg(cx.theme().colors().ghost_element_hover))
-                    .active(|style| style.bg(cx.theme().colors().ghost_element_active))
-                    .rounded_xs()
-                    .size_full()
+                FoldPlaceholder::fold_element(fold_id, cx)
                     .cursor_pointer()
                     .child("⋯")
                     .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
@@ -7583,6 +7577,7 @@ impl Editor {
                 constrain_width: false,
                 merge_adjacent: false,
                 type_tag: Some(type_id),
+                collapsed_text: None,
             };
             let creases = new_newlines
                 .into_iter()

crates/editor/src/folding_ranges.rs 🔗

@@ -130,6 +130,7 @@ mod tests {
     use gpui::TestAppContext;
     use lsp::FoldingRange;
     use multi_buffer::MultiBufferRow;
+    use pretty_assertions::assert_eq;
     use settings::DocumentFoldingRanges;
 
     use crate::{
@@ -668,4 +669,415 @@ mod tests {
             assert_eq!(editor.display_text(cx), "fn main() {⋯\n}\n",);
         });
     }
+
+    #[gpui::test]
+    async fn test_lsp_folding_ranges_collapsed_text(cx: &mut TestAppContext) {
+        init_test(cx, |_| {});
+
+        update_test_language_settings(cx, |settings| {
+            settings.defaults.document_folding_ranges = Some(DocumentFoldingRanges::On);
+        });
+
+        let mut cx = EditorLspTestContext::new_rust(
+            lsp::ServerCapabilities {
+                folding_range_provider: Some(lsp::FoldingRangeProviderCapability::Simple(true)),
+                ..lsp::ServerCapabilities::default()
+            },
+            cx,
+        )
+        .await;
+
+        let mut folding_request = cx
+            .set_request_handler::<lsp::request::FoldingRangeRequest, _, _>(
+                move |_, _, _| async move {
+                    Ok(Some(vec![
+                        // main: custom collapsed text
+                        FoldingRange {
+                            start_line: 0,
+                            start_character: Some(10),
+                            end_line: 4,
+                            end_character: Some(1),
+                            kind: None,
+                            collapsed_text: Some("{ fn body }".to_string()),
+                        },
+                        // other: collapsed text longer than the original folded content
+                        FoldingRange {
+                            start_line: 6,
+                            start_character: Some(11),
+                            end_line: 8,
+                            end_character: Some(1),
+                            kind: None,
+                            collapsed_text: Some("{ this collapsed text is intentionally much longer than the original function body it replaces }".to_string()),
+                        },
+                        // emoji: collapsed text WITH emoji and multi-byte chars
+                        FoldingRange {
+                            start_line: 10,
+                            start_character: Some(11),
+                            end_line: 13,
+                            end_character: Some(1),
+                            kind: None,
+                            collapsed_text: Some("{ 🦀…café }".to_string()),
+                        },
+                        // outer: collapsed text on the outer fn
+                        FoldingRange {
+                            start_line: 15,
+                            start_character: Some(11),
+                            end_line: 22,
+                            end_character: Some(1),
+                            kind: None,
+                            collapsed_text: Some("{ outer… }".to_string()),
+                        },
+                        // inner_a: nested inside outer, with collapsed text
+                        FoldingRange {
+                            start_line: 16,
+                            start_character: Some(17),
+                            end_line: 18,
+                            end_character: Some(5),
+                            kind: None,
+                            collapsed_text: Some("{ a }".to_string()),
+                        },
+                        // inner_b: nested inside outer, no collapsed text
+                        FoldingRange {
+                            start_line: 19,
+                            start_character: Some(17),
+                            end_line: 21,
+                            end_character: Some(5),
+                            kind: None,
+                            collapsed_text: None,
+                        },
+                        // newline: collapsed text containing \n
+                        FoldingRange {
+                            start_line: 24,
+                            start_character: Some(13),
+                            end_line: 27,
+                            end_character: Some(1),
+                            kind: None,
+                            collapsed_text: Some("{\n  …\n}".to_string()),
+                        },
+                    ]))
+                },
+            );
+
+        cx.set_state(
+            &[
+                "ˇfn main() {\n",
+                "    if true {\n",
+                "        println!(\"hello\");\n",
+                "    }\n",
+                "}\n",
+                "\n",
+                "fn other() {\n",
+                "    let x = 1;\n",
+                "}\n",
+                "\n",
+                "fn emoji() {\n",
+                "    let a = \"🦀🔥\";\n",
+                "    let b = \"café\";\n",
+                "}\n",
+                "\n",
+                "fn outer() {\n",
+                "    fn inner_a() {\n",
+                "        let x = 1;\n",
+                "    }\n",
+                "    fn inner_b() {\n",
+                "        let y = 2;\n",
+                "    }\n",
+                "}\n",
+                "\n",
+                "fn newline() {\n",
+                "    let a = 1;\n",
+                "    let b = 2;\n",
+                "}\n",
+            ]
+            .concat(),
+        );
+        assert!(folding_request.next().await.is_some());
+        cx.run_until_parked();
+
+        let unfolded_text = [
+            "fn main() {\n",
+            "    if true {\n",
+            "        println!(\"hello\");\n",
+            "    }\n",
+            "}\n",
+            "\n",
+            "fn other() {\n",
+            "    let x = 1;\n",
+            "}\n",
+            "\n",
+            "fn emoji() {\n",
+            "    let a = \"🦀🔥\";\n",
+            "    let b = \"café\";\n",
+            "}\n",
+            "\n",
+            "fn outer() {\n",
+            "    fn inner_a() {\n",
+            "        let x = 1;\n",
+            "    }\n",
+            "    fn inner_b() {\n",
+            "        let y = 2;\n",
+            "    }\n",
+            "}\n",
+            "\n",
+            "fn newline() {\n",
+            "    let a = 1;\n",
+            "    let b = 2;\n",
+            "}\n",
+        ]
+        .concat();
+
+        // Fold newline fn — collapsed text that itself contains \n
+        // (newlines are sanitized to spaces to keep folds single-line).
+        cx.update_editor(|editor, window, cx| {
+            editor.fold_at(MultiBufferRow(24), window, cx);
+        });
+        cx.update_editor(|editor, _window, cx| {
+            assert_eq!(
+                editor.display_text(cx),
+                [
+                    "fn main() {\n",
+                    "    if true {\n",
+                    "        println!(\"hello\");\n",
+                    "    }\n",
+                    "}\n",
+                    "\n",
+                    "fn other() {\n",
+                    "    let x = 1;\n",
+                    "}\n",
+                    "\n",
+                    "fn emoji() {\n",
+                    "    let a = \"🦀🔥\";\n",
+                    "    let b = \"café\";\n",
+                    "}\n",
+                    "\n",
+                    "fn outer() {\n",
+                    "    fn inner_a() {\n",
+                    "        let x = 1;\n",
+                    "    }\n",
+                    "    fn inner_b() {\n",
+                    "        let y = 2;\n",
+                    "    }\n",
+                    "}\n",
+                    "\n",
+                    "fn newline() {   … }\n",
+                ]
+                .concat(),
+            );
+        });
+
+        cx.update_editor(|editor, window, cx| {
+            editor.unfold_all(&crate::actions::UnfoldAll, window, cx);
+        });
+
+        // Fold main — custom collapsed text.
+        cx.update_editor(|editor, window, cx| {
+            editor.fold_at(MultiBufferRow(0), window, cx);
+        });
+        cx.update_editor(|editor, _window, cx| {
+            assert_eq!(
+                editor.display_text(cx),
+                [
+                    "fn main() { fn body }\n",
+                    "\n",
+                    "fn other() {\n",
+                    "    let x = 1;\n",
+                    "}\n",
+                    "\n",
+                    "fn emoji() {\n",
+                    "    let a = \"🦀🔥\";\n",
+                    "    let b = \"café\";\n",
+                    "}\n",
+                    "\n",
+                    "fn outer() {\n",
+                    "    fn inner_a() {\n",
+                    "        let x = 1;\n",
+                    "    }\n",
+                    "    fn inner_b() {\n",
+                    "        let y = 2;\n",
+                    "    }\n",
+                    "}\n",
+                    "\n",
+                    "fn newline() {\n",
+                    "    let a = 1;\n",
+                    "    let b = 2;\n",
+                    "}\n",
+                ]
+                .concat(),
+            );
+        });
+
+        // Fold emoji fn — multi-byte / emoji collapsed text (main still folded).
+        cx.update_editor(|editor, window, cx| {
+            editor.fold_at(MultiBufferRow(10), window, cx);
+        });
+        cx.update_editor(|editor, _window, cx| {
+            assert_eq!(
+                editor.display_text(cx),
+                [
+                    "fn main() { fn body }\n",
+                    "\n",
+                    "fn other() {\n",
+                    "    let x = 1;\n",
+                    "}\n",
+                    "\n",
+                    "fn emoji() { 🦀…café }\n",
+                    "\n",
+                    "fn outer() {\n",
+                    "    fn inner_a() {\n",
+                    "        let x = 1;\n",
+                    "    }\n",
+                    "    fn inner_b() {\n",
+                    "        let y = 2;\n",
+                    "    }\n",
+                    "}\n",
+                    "\n",
+                    "fn newline() {\n",
+                    "    let a = 1;\n",
+                    "    let b = 2;\n",
+                    "}\n",
+                ]
+                .concat(),
+            );
+        });
+
+        // Fold a nested range (inner_a) while outer is still unfolded.
+        cx.update_editor(|editor, window, cx| {
+            editor.fold_at(MultiBufferRow(16), window, cx);
+        });
+        cx.update_editor(|editor, _window, cx| {
+            assert_eq!(
+                editor.display_text(cx),
+                [
+                    "fn main() { fn body }\n",
+                    "\n",
+                    "fn other() {\n",
+                    "    let x = 1;\n",
+                    "}\n",
+                    "\n",
+                    "fn emoji() { 🦀…café }\n",
+                    "\n",
+                    "fn outer() {\n",
+                    "    fn inner_a() { a }\n",
+                    "    fn inner_b() {\n",
+                    "        let y = 2;\n",
+                    "    }\n",
+                    "}\n",
+                    "\n",
+                    "fn newline() {\n",
+                    "    let a = 1;\n",
+                    "    let b = 2;\n",
+                    "}\n",
+                ]
+                .concat(),
+            );
+        });
+
+        // Unfold everything to reset.
+        cx.update_editor(|editor, window, cx| {
+            editor.unfold_all(&crate::actions::UnfoldAll, window, cx);
+        });
+        cx.update_editor(|editor, _window, cx| {
+            assert_eq!(editor.display_text(cx), unfolded_text);
+        });
+
+        // Fold ALL at once and verify every fold.
+        cx.update_editor(|editor, window, cx| {
+            editor.fold_all(&crate::actions::FoldAll, window, cx);
+        });
+        cx.update_editor(|editor, _window, cx| {
+            assert_eq!(
+                editor.display_text(cx),
+                [
+                    "fn main() { fn body }\n",
+                    "\n",
+                    "fn other() { this collapsed text is intentionally much longer than the original function body it replaces }\n",
+                    "\n",
+                    "fn emoji() { 🦀…café }\n",
+                    "\n",
+                    "fn outer() { outer… }\n",
+                    "\n",
+                    "fn newline() {   … }\n",
+                ]
+                .concat(),
+            );
+        });
+
+        // Unfold all again, then fold only the outer, which should swallow inner folds.
+        cx.update_editor(|editor, window, cx| {
+            editor.unfold_all(&crate::actions::UnfoldAll, window, cx);
+        });
+        cx.update_editor(|editor, window, cx| {
+            editor.fold_at(MultiBufferRow(15), window, cx);
+        });
+        cx.update_editor(|editor, _window, cx| {
+            assert_eq!(
+                editor.display_text(cx),
+                [
+                    "fn main() {\n",
+                    "    if true {\n",
+                    "        println!(\"hello\");\n",
+                    "    }\n",
+                    "}\n",
+                    "\n",
+                    "fn other() {\n",
+                    "    let x = 1;\n",
+                    "}\n",
+                    "\n",
+                    "fn emoji() {\n",
+                    "    let a = \"🦀🔥\";\n",
+                    "    let b = \"café\";\n",
+                    "}\n",
+                    "\n",
+                    "fn outer() { outer… }\n",
+                    "\n",
+                    "fn newline() {\n",
+                    "    let a = 1;\n",
+                    "    let b = 2;\n",
+                    "}\n",
+                ]
+                .concat(),
+            );
+        });
+
+        // Unfold the outer, then fold both inners independently.
+        cx.update_editor(|editor, window, cx| {
+            editor.unfold_all(&crate::actions::UnfoldAll, window, cx);
+        });
+        cx.update_editor(|editor, window, cx| {
+            editor.fold_at(MultiBufferRow(16), window, cx);
+            editor.fold_at(MultiBufferRow(19), window, cx);
+        });
+        cx.update_editor(|editor, _window, cx| {
+            assert_eq!(
+                editor.display_text(cx),
+                [
+                    "fn main() {\n",
+                    "    if true {\n",
+                    "        println!(\"hello\");\n",
+                    "    }\n",
+                    "}\n",
+                    "\n",
+                    "fn other() {\n",
+                    "    let x = 1;\n",
+                    "}\n",
+                    "\n",
+                    "fn emoji() {\n",
+                    "    let a = \"🦀🔥\";\n",
+                    "    let b = \"café\";\n",
+                    "}\n",
+                    "\n",
+                    "fn outer() {\n",
+                    "    fn inner_a() { a }\n",
+                    "    fn inner_b() ⋯\n",
+                    "}\n",
+                    "\n",
+                    "fn newline() {\n",
+                    "    let a = 1;\n",
+                    "    let b = 2;\n",
+                    "}\n",
+                ]
+                .concat(),
+            );
+        });
+    }
 }

crates/lsp/src/lsp.rs 🔗

@@ -966,7 +966,7 @@ impl LanguageServer {
                         line_folding_only: Some(false),
                         range_limit: None,
                         folding_range: Some(FoldingRangeCapability {
-                            collapsed_text: Some(false),
+                            collapsed_text: Some(true),
                         }),
                         folding_range_kind: Some(FoldingRangeKindCapability {
                             value_set: Some(vec![

crates/project/src/lsp_command.rs 🔗

@@ -6,7 +6,7 @@ use crate::{
     InlayHintLabel, InlayHintLabelPart, InlayHintLabelPartTooltip, InlayHintTooltip, Location,
     LocationLink, LspAction, LspPullDiagnostics, MarkupContent, PrepareRenameResponse,
     ProjectTransaction, PulledDiagnostics, ResolveState,
-    lsp_store::{LocalLspStore, LspStore},
+    lsp_store::{LocalLspStore, LspFoldingRange, LspStore},
 };
 use anyhow::{Context as _, Result};
 use async_trait::async_trait;
@@ -4737,7 +4737,7 @@ impl LspCommand for GetDocumentColor {
 
 #[async_trait(?Send)]
 impl LspCommand for GetFoldingRanges {
-    type Response = Vec<Range<Anchor>>;
+    type Response = Vec<LspFoldingRange>;
     type LspRequest = lsp::request::FoldingRangeRequest;
     type ProtoRequest = proto::GetFoldingRanges;
 
@@ -4786,17 +4786,32 @@ impl LspCommand for GetFoldingRanges {
             .into_iter()
             .filter(|range| range.start_line < range.end_line)
             .filter(|range| range.start_line <= max_point.row && range.end_line <= max_point.row)
-            .map(|range| {
-                let start_col = range
+            .map(|folding_range| {
+                let start_col = folding_range
                     .start_character
-                    .unwrap_or(snapshot.line_len(range.start_line));
-                let end_col = range
+                    .unwrap_or(snapshot.line_len(folding_range.start_line));
+                let end_col = folding_range
                     .end_character
-                    .unwrap_or(snapshot.line_len(range.end_line));
-                let start =
-                    snapshot.anchor_after(language::Point::new(range.start_line, start_col));
-                let end = snapshot.anchor_before(language::Point::new(range.end_line, end_col));
-                start..end
+                    .unwrap_or(snapshot.line_len(folding_range.end_line));
+                let start = snapshot
+                    .anchor_after(language::Point::new(folding_range.start_line, start_col));
+                let end =
+                    snapshot.anchor_before(language::Point::new(folding_range.end_line, end_col));
+                let collapsed_text =
+                    folding_range
+                        .collapsed_text
+                        .filter(|t| !t.is_empty())
+                        .map(|t| {
+                            if t.contains('\n') {
+                                SharedString::from(t.replace('\n', " "))
+                            } else {
+                                SharedString::from(t)
+                            }
+                        });
+                LspFoldingRange {
+                    range: start..end,
+                    collapsed_text,
+                }
             })
             .collect())
     }
@@ -4825,8 +4840,20 @@ impl LspCommand for GetFoldingRanges {
         buffer_version: &clock::Global,
         _: &mut App,
     ) -> proto::GetFoldingRangesResponse {
+        let mut ranges = Vec::with_capacity(response.len());
+        let mut collapsed_texts = Vec::with_capacity(response.len());
+        for folding_range in response {
+            ranges.push(serialize_anchor_range(folding_range.range));
+            collapsed_texts.push(
+                folding_range
+                    .collapsed_text
+                    .map(|t| t.to_string())
+                    .unwrap_or_default(),
+            );
+        }
         proto::GetFoldingRangesResponse {
-            ranges: response.into_iter().map(serialize_anchor_range).collect(),
+            ranges,
+            collapsed_texts,
             version: serialize_version(buffer_version),
         }
     }
@@ -4841,7 +4868,21 @@ impl LspCommand for GetFoldingRanges {
         message
             .ranges
             .into_iter()
-            .map(deserialize_anchor_range)
+            .zip(
+                message
+                    .collapsed_texts
+                    .into_iter()
+                    .map(Some)
+                    .chain(std::iter::repeat(None)),
+            )
+            .map(|(range, collapsed_text)| {
+                Ok(LspFoldingRange {
+                    range: deserialize_anchor_range(range)?,
+                    collapsed_text: collapsed_text
+                        .filter(|t| !t.is_empty())
+                        .map(SharedString::from),
+                })
+            })
             .collect()
     }
 

crates/project/src/lsp_store.rs 🔗

@@ -142,6 +142,7 @@ use util::{
 };
 
 pub use document_colors::DocumentColors;
+pub use folding_ranges::LspFoldingRange;
 pub use fs::*;
 pub use language::Location;
 pub use lsp_store::inlay_hints::{CacheInlayHints, InvalidationStrategy};

crates/project/src/lsp_store/folding_ranges.rs 🔗

@@ -7,7 +7,7 @@ use clock::Global;
 use collections::HashMap;
 use futures::FutureExt as _;
 use futures::future::{Shared, join_all};
-use gpui::{AppContext as _, Context, Entity, Task};
+use gpui::{AppContext as _, Context, Entity, SharedString, Task};
 use itertools::Itertools;
 use language::Buffer;
 use lsp::{LSP_REQUEST_TIMEOUT, LanguageServerId};
@@ -16,12 +16,18 @@ use text::Anchor;
 use crate::lsp_command::{GetFoldingRanges, LspCommand as _};
 use crate::lsp_store::LspStore;
 
+#[derive(Clone, Debug)]
+pub struct LspFoldingRange {
+    pub range: Range<Anchor>,
+    pub collapsed_text: Option<SharedString>,
+}
+
 pub(super) type FoldingRangeTask =
-    Shared<Task<std::result::Result<Vec<Range<Anchor>>, Arc<anyhow::Error>>>>;
+    Shared<Task<std::result::Result<Vec<LspFoldingRange>, Arc<anyhow::Error>>>>;
 
 #[derive(Debug, Default)]
 pub(super) struct FoldingRangeData {
-    pub(super) ranges: HashMap<LanguageServerId, Vec<Range<Anchor>>>,
+    pub(super) ranges: HashMap<LanguageServerId, Vec<LspFoldingRange>>,
     ranges_update: Option<(Global, FoldingRangeTask)>,
 }
 
@@ -34,7 +40,7 @@ impl LspStore {
         &mut self,
         buffer: &Entity<Buffer>,
         cx: &mut Context<Self>,
-    ) -> Task<Vec<Range<Anchor>>> {
+    ) -> Task<Vec<LspFoldingRange>> {
         let version_queried_for = buffer.read(cx).version();
         let buffer_id = buffer.read(cx).remote_id();
 
@@ -61,7 +67,7 @@ impl LspStore {
                                 .values()
                                 .flatten()
                                 .cloned()
-                                .sorted_by(|a, b| a.start.cmp(&b.start, &snapshot))
+                                .sorted_by(|a, b| a.range.start.cmp(&b.range.start, &snapshot))
                                 .collect(),
                         );
                     }
@@ -133,7 +139,7 @@ impl LspStore {
                             .values()
                             .flatten()
                             .cloned()
-                            .sorted_by(|a, b| a.start.cmp(&b.start, &snapshot))
+                            .sorted_by(|a, b| a.range.start.cmp(&b.range.start, &snapshot))
                             .collect()
                     })
                     .map_err(Arc::new)
@@ -149,7 +155,7 @@ impl LspStore {
         &mut self,
         buffer: &Entity<Buffer>,
         cx: &mut Context<Self>,
-    ) -> Task<anyhow::Result<Option<HashMap<LanguageServerId, Vec<Range<Anchor>>>>>> {
+    ) -> Task<anyhow::Result<Option<HashMap<LanguageServerId, Vec<LspFoldingRange>>>>> {
         if let Some((client, project_id)) = self.upstream_client() {
             let request = GetFoldingRanges;
             if !self.is_capable_for_proto_request(buffer, &request, cx) {

crates/proto/proto/lsp.proto 🔗

@@ -1015,4 +1015,5 @@ message GetFoldingRanges {
 message GetFoldingRangesResponse {
   repeated AnchorRange ranges = 1;
   repeated VectorClockEntry version = 2;
+  repeated string collapsed_texts = 3;
 }