@@ -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()
@@ -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(),
+ );
+ });
+ }
}