Improve context expansion (#10957)

Mikayla Maki and conrad created

Release Notes:

- Improved expand excerpt indicators to allow unidirectional expansion.
Also added the `editor::ExpandExcerptsUp` and
`editor::ExpandExcerptsDown` actions, which can both take a `lines`
parameter. Also added a `expand_excerpt_lines` setting which controls
the default number of lines that the indicators and actions use.

---------

Co-authored-by: conrad <conrad@zed.dev>

Change summary

assets/icons/arrow_down_from_line.svg             |   1 
assets/icons/arrow_up_from_line.svg               |   1 
assets/settings/default.json                      |   2 
crates/assistant2/src/tools/annotate_code.rs      |   2 
crates/auto_update/src/auto_update.rs             |   5 
crates/collab/src/tests/following_tests.rs        |   5 
crates/copilot/src/copilot_completion_provider.rs |  12 
crates/diagnostics/src/diagnostics.rs             |   6 
crates/diagnostics/src/diagnostics_tests.rs       |  79 ++--
crates/editor/src/actions.rs                      |  14 
crates/editor/src/display_map.rs                  |  33 +
crates/editor/src/display_map/block_map.rs        | 275 ++++++++++++---
crates/editor/src/editor.rs                       |  80 +++
crates/editor/src/editor_settings.rs              |   6 
crates/editor/src/editor_tests.rs                 |  57 ++-
crates/editor/src/element.rs                      | 282 +++++++++++++---
crates/editor/src/hunk_diff.rs                    |   2 
crates/editor/src/inlay_hint_cache.rs             |  13 
crates/editor/src/items.rs                        |  39 +-
crates/editor/src/movement.rs                     |   5 
crates/editor/src/rust_analyzer_ext.rs            |   4 
crates/editor/src/test.rs                         |   6 
crates/editor/src/test/editor_test_context.rs     |   5 
crates/multi_buffer/src/multi_buffer.rs           | 211 +++++++++---
crates/search/src/project_search.rs               |  34 +-
crates/ui/src/components/icon.rs                  |   4 
crates/zed/src/zed.rs                             |   9 
27 files changed, 884 insertions(+), 308 deletions(-)

Detailed changes

assets/icons/arrow_down_from_line.svg 🔗

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-down-from-line"><path d="M19 3H5"/><path d="M12 21V7"/><path d="m6 15 6 6 6-6"/></svg>

assets/icons/arrow_up_from_line.svg 🔗

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-up-from-line"><path d="m18 9-6-6-6 6"/><path d="M12 3v14"/><path d="M5 21h14"/></svg>

assets/settings/default.json 🔗

@@ -124,6 +124,8 @@
   "wrap_guides": [],
   // Hide the values of in variables from visual display in private files
   "redact_private_values": false,
+  // The default number of lines to expand excerpts in the multibuffer by.
+  "expand_excerpt_lines": 3,
   // Globs to match against file paths to determine if a file is private.
   "private_files": [
     "**/.env*",

crates/assistant2/src/tools/annotate_code.rs 🔗

@@ -253,7 +253,7 @@ impl ToolView for AnnotationResultView {
                 MultiBuffer::new(0, language::Capability::ReadWrite).with_title(String::new())
             });
             let editor = cx.new_view(|cx| {
-                Editor::for_multibuffer(multibuffer.clone(), Some(self.project.clone()), cx)
+                Editor::for_multibuffer(multibuffer.clone(), Some(self.project.clone()), true, cx)
             });
 
             self.editor = Some(editor.clone());

crates/auto_update/src/auto_update.rs 🔗

@@ -237,8 +237,9 @@ fn view_release_notes_locally(workspace: &mut Workspace, cx: &mut ViewContext<Wo
                             let buffer = cx.new_model(|cx| MultiBuffer::singleton(buffer, cx));
 
                             let tab_description = SharedString::from(body.title.to_string());
-                            let editor = cx
-                                .new_view(|cx| Editor::for_multibuffer(buffer, Some(project), cx));
+                            let editor = cx.new_view(|cx| {
+                                Editor::for_multibuffer(buffer, Some(project), true, cx)
+                            });
                             let workspace_handle = workspace.weak_handle();
                             let view: View<MarkdownPreviewView> = MarkdownPreviewView::new(
                                 MarkdownPreviewMode::Default,

crates/collab/src/tests/following_tests.rs 🔗

@@ -308,8 +308,9 @@ async fn test_basic_following(
         result
     });
     let multibuffer_editor_a = workspace_a.update(cx_a, |workspace, cx| {
-        let editor =
-            cx.new_view(|cx| Editor::for_multibuffer(multibuffer_a, Some(project_a.clone()), cx));
+        let editor = cx.new_view(|cx| {
+            Editor::for_multibuffer(multibuffer_a, Some(project_a.clone()), true, cx)
+        });
         workspace.add_item_to_active_pane(Box::new(editor.clone()), None, cx);
         editor
     });

crates/copilot/src/copilot_completion_provider.rs 🔗

@@ -781,7 +781,7 @@ mod tests {
             );
             multibuffer
         });
-        let editor = cx.add_window(|cx| Editor::for_multibuffer(multibuffer, None, cx));
+        let editor = cx.add_window(|cx| Editor::for_multibuffer(multibuffer, None, true, cx));
         editor.update(cx, |editor, cx| editor.focus(cx)).unwrap();
         let copilot_provider = cx.new_model(|_| CopilotCompletionProvider::new(copilot));
         editor
@@ -811,7 +811,7 @@ mod tests {
             assert!(editor.has_active_inline_completion(cx));
             assert_eq!(
                 editor.display_text(cx),
-                "\n\na = 1\nb = 2 + a\n\n\n\nc = 3\nd = 4\n"
+                "\n\n\na = 1\nb = 2 + a\n\n\n\n\n\nc = 3\nd = 4\n\n"
             );
             assert_eq!(editor.text(cx), "a = 1\nb = 2\n\nc = 3\nd = 4\n");
         });
@@ -833,7 +833,7 @@ mod tests {
             assert!(!editor.has_active_inline_completion(cx));
             assert_eq!(
                 editor.display_text(cx),
-                "\n\na = 1\nb = 2\n\n\n\nc = 3\nd = 4\n"
+                "\n\n\na = 1\nb = 2\n\n\n\n\n\nc = 3\nd = 4\n\n"
             );
             assert_eq!(editor.text(cx), "a = 1\nb = 2\n\nc = 3\nd = 4\n");
 
@@ -842,7 +842,7 @@ mod tests {
             assert!(!editor.has_active_inline_completion(cx));
             assert_eq!(
                 editor.display_text(cx),
-                "\n\na = 1\nb = 2\n\n\n\nc = 3\nd = 4 \n"
+                "\n\n\na = 1\nb = 2\n\n\n\n\n\nc = 3\nd = 4 \n\n"
             );
             assert_eq!(editor.text(cx), "a = 1\nb = 2\n\nc = 3\nd = 4 \n");
         });
@@ -853,7 +853,7 @@ mod tests {
             assert!(editor.has_active_inline_completion(cx));
             assert_eq!(
                 editor.display_text(cx),
-                "\n\na = 1\nb = 2\n\n\n\nc = 3\nd = 4 + c\n"
+                "\n\n\na = 1\nb = 2\n\n\n\n\n\nc = 3\nd = 4 + c\n\n"
             );
             assert_eq!(editor.text(cx), "a = 1\nb = 2\n\nc = 3\nd = 4 \n");
         });
@@ -1032,7 +1032,7 @@ mod tests {
             );
             multibuffer
         });
-        let editor = cx.add_window(|cx| Editor::for_multibuffer(multibuffer, None, cx));
+        let editor = cx.add_window(|cx| Editor::for_multibuffer(multibuffer, None, true, cx));
         let copilot_provider = cx.new_model(|_| CopilotCompletionProvider::new(copilot));
         editor
             .update(cx, |editor, cx| {

crates/diagnostics/src/diagnostics.rs 🔗

@@ -161,7 +161,7 @@ impl ProjectDiagnosticsEditor {
         });
         let editor = cx.new_view(|cx| {
             let mut editor =
-                Editor::for_multibuffer(excerpts.clone(), Some(project_handle.clone()), cx);
+                Editor::for_multibuffer(excerpts.clone(), Some(project_handle.clone()), false, cx);
             editor.set_vertical_scroll_margin(5, cx);
             editor
         });
@@ -792,13 +792,15 @@ impl Item for ProjectDiagnosticsEditor {
     }
 }
 
+const DIAGNOSTIC_HEADER: &'static str = "diagnostic header";
+
 fn diagnostic_header_renderer(diagnostic: Diagnostic) -> RenderBlock {
     let (message, code_ranges) = highlight_diagnostic_message(&diagnostic);
     let message: SharedString = message;
     Box::new(move |cx| {
         let highlight_style: HighlightStyle = cx.theme().colors().text_accent.into();
         h_flex()
-            .id("diagnostic header")
+            .id(DIAGNOSTIC_HEADER)
             .py_2()
             .pl_10()
             .pr_5()

crates/diagnostics/src/diagnostics_tests.rs 🔗

@@ -158,11 +158,11 @@ async fn test_diagnostics(cx: &mut TestAppContext) {
     assert_eq!(
         editor_blocks(&editor, cx),
         [
-            (DisplayRow(0), "path header block".into()),
-            (DisplayRow(2), "diagnostic header".into()),
-            (DisplayRow(15), "collapsed context".into()),
-            (DisplayRow(16), "diagnostic header".into()),
-            (DisplayRow(25), "collapsed context".into()),
+            (DisplayRow(0), FILE_HEADER.into()),
+            (DisplayRow(2), DIAGNOSTIC_HEADER.into()),
+            (DisplayRow(15), EXCERPT_HEADER.into()),
+            (DisplayRow(16), DIAGNOSTIC_HEADER.into()),
+            (DisplayRow(25), EXCERPT_HEADER.into()),
         ]
     );
     assert_eq!(
@@ -243,13 +243,13 @@ async fn test_diagnostics(cx: &mut TestAppContext) {
     assert_eq!(
         editor_blocks(&editor, cx),
         [
-            (DisplayRow(0), "path header block".into()),
-            (DisplayRow(2), "diagnostic header".into()),
-            (DisplayRow(7), "path header block".into()),
-            (DisplayRow(9), "diagnostic header".into()),
-            (DisplayRow(22), "collapsed context".into()),
-            (DisplayRow(23), "diagnostic header".into()),
-            (DisplayRow(32), "collapsed context".into()),
+            (DisplayRow(0), FILE_HEADER.into()),
+            (DisplayRow(2), DIAGNOSTIC_HEADER.into()),
+            (DisplayRow(7), FILE_HEADER.into()),
+            (DisplayRow(9), DIAGNOSTIC_HEADER.into()),
+            (DisplayRow(22), EXCERPT_HEADER.into()),
+            (DisplayRow(23), DIAGNOSTIC_HEADER.into()),
+            (DisplayRow(32), EXCERPT_HEADER.into()),
         ]
     );
 
@@ -355,15 +355,15 @@ async fn test_diagnostics(cx: &mut TestAppContext) {
     assert_eq!(
         editor_blocks(&editor, cx),
         [
-            (DisplayRow(0), "path header block".into()),
-            (DisplayRow(2), "diagnostic header".into()),
-            (DisplayRow(7), "collapsed context".into()),
-            (DisplayRow(8), "diagnostic header".into()),
-            (DisplayRow(13), "path header block".into()),
-            (DisplayRow(15), "diagnostic header".into()),
-            (DisplayRow(28), "collapsed context".into()),
-            (DisplayRow(29), "diagnostic header".into()),
-            (DisplayRow(38), "collapsed context".into()),
+            (DisplayRow(0), FILE_HEADER.into()),
+            (DisplayRow(2), DIAGNOSTIC_HEADER.into()),
+            (DisplayRow(7), EXCERPT_HEADER.into()),
+            (DisplayRow(8), DIAGNOSTIC_HEADER.into()),
+            (DisplayRow(13), FILE_HEADER.into()),
+            (DisplayRow(15), DIAGNOSTIC_HEADER.into()),
+            (DisplayRow(28), EXCERPT_HEADER.into()),
+            (DisplayRow(29), DIAGNOSTIC_HEADER.into()),
+            (DisplayRow(38), EXCERPT_HEADER.into()),
         ]
     );
 
@@ -493,8 +493,8 @@ async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) {
     assert_eq!(
         editor_blocks(&editor, cx),
         [
-            (DisplayRow(0), "path header block".into()),
-            (DisplayRow(2), "diagnostic header".into()),
+            (DisplayRow(0), FILE_HEADER.into()),
+            (DisplayRow(2), DIAGNOSTIC_HEADER.into()),
         ]
     );
     assert_eq!(
@@ -539,10 +539,10 @@ async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) {
     assert_eq!(
         editor_blocks(&editor, cx),
         [
-            (DisplayRow(0), "path header block".into()),
-            (DisplayRow(2), "diagnostic header".into()),
-            (DisplayRow(6), "collapsed context".into()),
-            (DisplayRow(7), "diagnostic header".into()),
+            (DisplayRow(0), FILE_HEADER.into()),
+            (DisplayRow(2), DIAGNOSTIC_HEADER.into()),
+            (DisplayRow(6), EXCERPT_HEADER.into()),
+            (DisplayRow(7), DIAGNOSTIC_HEADER.into()),
         ]
     );
     assert_eq!(
@@ -605,10 +605,10 @@ async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) {
     assert_eq!(
         editor_blocks(&editor, cx),
         [
-            (DisplayRow(0), "path header block".into()),
-            (DisplayRow(2), "diagnostic header".into()),
-            (DisplayRow(7), "collapsed context".into()),
-            (DisplayRow(8), "diagnostic header".into()),
+            (DisplayRow(0), FILE_HEADER.into()),
+            (DisplayRow(2), DIAGNOSTIC_HEADER.into()),
+            (DisplayRow(7), EXCERPT_HEADER.into()),
+            (DisplayRow(8), DIAGNOSTIC_HEADER.into()),
         ]
     );
     assert_eq!(
@@ -661,10 +661,10 @@ async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) {
     assert_eq!(
         editor_blocks(&editor, cx),
         [
-            (DisplayRow(0), "path header block".into()),
-            (DisplayRow(2), "diagnostic header".into()),
-            (DisplayRow(7), "collapsed context".into()),
-            (DisplayRow(8), "diagnostic header".into()),
+            (DisplayRow(0), FILE_HEADER.into()),
+            (DisplayRow(2), DIAGNOSTIC_HEADER.into()),
+            (DisplayRow(7), EXCERPT_HEADER.into()),
+            (DisplayRow(8), DIAGNOSTIC_HEADER.into()),
         ]
     );
     assert_eq!(
@@ -958,6 +958,10 @@ fn random_diagnostic(
     }
 }
 
+const FILE_HEADER: &'static str = "file header";
+const EXCERPT_HEADER: &'static str = "excerpt header";
+const EXCERPT_FOOTER: &'static str = "excerpt footer";
+
 fn editor_blocks(
     editor: &View<Editor>,
     cx: &mut VisualTestContext,
@@ -996,11 +1000,12 @@ fn editor_blocks(
                                 starts_new_buffer, ..
                             } => {
                                 if *starts_new_buffer {
-                                    "path header block".into()
+                                    FILE_HEADER.into()
                                 } else {
-                                    "collapsed context".into()
+                                    EXCERPT_HEADER.into()
                                 }
                             }
+                            TransformBlock::ExcerptFooter { .. } => EXCERPT_FOOTER.into(),
                         };
 
                         Some((row, name))

crates/editor/src/actions.rs 🔗

@@ -114,12 +114,26 @@ pub struct ExpandExcerpts {
     pub(super) lines: u32,
 }
 
+#[derive(PartialEq, Clone, Deserialize, Default)]
+pub struct ExpandExcerptsUp {
+    #[serde(default)]
+    pub(super) lines: u32,
+}
+
+#[derive(PartialEq, Clone, Deserialize, Default)]
+pub struct ExpandExcerptsDown {
+    #[serde(default)]
+    pub(super) lines: u32,
+}
+
 impl_actions!(
     editor,
     [
         ConfirmCodeAction,
         ConfirmCompletion,
         ExpandExcerpts,
+        ExpandExcerptsUp,
+        ExpandExcerptsDown,
         FoldAt,
         MoveDownByLines,
         MovePageDown,

crates/editor/src/display_map.rs 🔗

@@ -112,8 +112,10 @@ impl DisplayMap {
         font: Font,
         font_size: Pixels,
         wrap_width: Option<Pixels>,
+        show_excerpt_controls: bool,
         buffer_header_height: u8,
         excerpt_header_height: u8,
+        excerpt_footer_height: u8,
         fold_placeholder: FoldPlaceholder,
         cx: &mut ModelContext<Self>,
     ) -> Self {
@@ -124,8 +126,15 @@ impl DisplayMap {
         let (fold_map, snapshot) = FoldMap::new(snapshot);
         let (tab_map, snapshot) = TabMap::new(snapshot, tab_size);
         let (wrap_map, snapshot) = WrapMap::new(snapshot, font, font_size, wrap_width, cx);
-        let block_map = BlockMap::new(snapshot, buffer_header_height, excerpt_header_height);
+        let block_map = BlockMap::new(
+            snapshot,
+            show_excerpt_controls,
+            buffer_header_height,
+            excerpt_header_height,
+            excerpt_footer_height,
+        );
         let flap_map = FlapMap::default();
+
         cx.observe(&wrap_map, |_, _, cx| cx.notify()).detach();
 
         DisplayMap {
@@ -380,6 +389,10 @@ impl DisplayMap {
     pub fn is_rewrapping(&self, cx: &gpui::AppContext) -> bool {
         self.wrap_map.read(cx).is_rewrapping()
     }
+
+    pub fn show_excerpt_controls(&self) -> bool {
+        self.block_map.show_excerpt_controls()
+    }
 }
 
 #[derive(Debug, Default)]
@@ -1098,8 +1111,10 @@ pub mod tests {
                 font("Helvetica"),
                 font_size,
                 wrap_width,
+                true,
                 buffer_start_excerpt_header_height,
                 excerpt_header_height,
+                0,
                 FoldPlaceholder::test(),
                 cx,
             )
@@ -1344,8 +1359,10 @@ pub mod tests {
                     font("Helvetica"),
                     font_size,
                     wrap_width,
+                    true,
                     1,
                     1,
+                    0,
                     FoldPlaceholder::test(),
                     cx,
                 )
@@ -1453,8 +1470,10 @@ pub mod tests {
                 font("Helvetica"),
                 font_size,
                 None,
+                true,
                 1,
                 1,
+                0,
                 FoldPlaceholder::test(),
                 cx,
             )
@@ -1549,6 +1568,8 @@ pub mod tests {
                 font("Helvetica"),
                 font_size,
                 None,
+                true,
+                1,
                 1,
                 1,
                 FoldPlaceholder::test(),
@@ -1650,8 +1671,10 @@ pub mod tests {
                 font("Courier"),
                 font_size,
                 Some(px(40.0)),
+                true,
                 1,
                 1,
+                0,
                 FoldPlaceholder::test(),
                 cx,
             )
@@ -1732,6 +1755,8 @@ pub mod tests {
                 font("Courier"),
                 font_size,
                 None,
+                true,
+                1,
                 1,
                 1,
                 FoldPlaceholder::test(),
@@ -1856,8 +1881,10 @@ pub mod tests {
                 font("Helvetica"),
                 font_size,
                 None,
+                true,
                 1,
                 1,
+                0,
                 FoldPlaceholder::test(),
                 cx,
             );
@@ -1893,8 +1920,10 @@ pub mod tests {
                 font("Helvetica"),
                 font_size,
                 None,
+                true,
                 1,
                 1,
+                0,
                 FoldPlaceholder::test(),
                 cx,
             )
@@ -1968,8 +1997,10 @@ pub mod tests {
                 font("Helvetica"),
                 font_size,
                 None,
+                true,
                 1,
                 1,
+                0,
                 FoldPlaceholder::test(),
                 cx,
             )

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

@@ -12,7 +12,7 @@ use std::{
     cell::RefCell,
     cmp::{self, Ordering},
     fmt::Debug,
-    ops::{Deref, DerefMut, Range},
+    ops::{Deref, DerefMut, Range, RangeBounds},
     sync::{
         atomic::{AtomicUsize, Ordering::SeqCst},
         Arc,
@@ -31,8 +31,10 @@ pub struct BlockMap {
     wrap_snapshot: RefCell<WrapSnapshot>,
     blocks: Vec<Arc<Block>>,
     transforms: RefCell<SumTree<Transform>>,
+    show_excerpt_controls: bool,
     buffer_header_height: u8,
     excerpt_header_height: u8,
+    excerpt_footer_height: u8,
 }
 
 pub struct BlockMapWriter<'a>(&'a mut BlockMap);
@@ -92,6 +94,7 @@ pub struct BlockContext<'a, 'b> {
     pub editor_style: &'b EditorStyle,
 }
 
+/// Whether the block should be considered above or below the anchor line
 #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
 pub enum BlockDisposition {
     Above,
@@ -104,6 +107,17 @@ struct Transform {
     block: Option<TransformBlock>,
 }
 
+pub(crate) enum BlockType {
+    Custom(BlockId),
+    Header,
+    Footer,
+}
+
+pub(crate) trait BlockLike {
+    fn block_type(&self) -> BlockType;
+    fn disposition(&self) -> BlockDisposition;
+}
+
 #[allow(clippy::large_enum_variant)]
 #[derive(Clone)]
 pub enum TransformBlock {
@@ -114,7 +128,27 @@ pub enum TransformBlock {
         range: ExcerptRange<text::Anchor>,
         height: u8,
         starts_new_buffer: bool,
+        show_excerpt_controls: bool,
     },
+    ExcerptFooter {
+        id: ExcerptId,
+        disposition: BlockDisposition,
+        height: u8,
+    },
+}
+
+impl BlockLike for TransformBlock {
+    fn block_type(&self) -> BlockType {
+        match self {
+            TransformBlock::Custom(block) => BlockType::Custom(block.id),
+            TransformBlock::ExcerptHeader { .. } => BlockType::Header,
+            TransformBlock::ExcerptFooter { .. } => BlockType::Footer,
+        }
+    }
+
+    fn disposition(&self) -> BlockDisposition {
+        self.disposition()
+    }
 }
 
 impl TransformBlock {
@@ -122,6 +156,7 @@ impl TransformBlock {
         match self {
             TransformBlock::Custom(block) => block.disposition,
             TransformBlock::ExcerptHeader { .. } => BlockDisposition::Above,
+            TransformBlock::ExcerptFooter { disposition, .. } => *disposition,
         }
     }
 
@@ -129,6 +164,7 @@ impl TransformBlock {
         match self {
             TransformBlock::Custom(block) => block.height,
             TransformBlock::ExcerptHeader { height, .. } => *height,
+            TransformBlock::ExcerptFooter { height, .. } => *height,
         }
     }
 }
@@ -137,9 +173,23 @@ impl Debug for TransformBlock {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
         match self {
             Self::Custom(block) => f.debug_struct("Custom").field("block", block).finish(),
-            Self::ExcerptHeader { buffer, .. } => f
+            Self::ExcerptHeader {
+                buffer,
+                starts_new_buffer,
+                id,
+                ..
+            } => f
                 .debug_struct("ExcerptHeader")
+                .field("id", &id)
                 .field("path", &buffer.file().map(|f| f.path()))
+                .field("starts_new_buffer", &starts_new_buffer)
+                .finish(),
+            TransformBlock::ExcerptFooter {
+                id, disposition, ..
+            } => f
+                .debug_struct("ExcerptFooter")
+                .field("id", &id)
+                .field("disposition", &disposition)
                 .finish(),
         }
     }
@@ -170,8 +220,10 @@ pub struct BlockBufferRows<'a> {
 impl BlockMap {
     pub fn new(
         wrap_snapshot: WrapSnapshot,
+        show_excerpt_controls: bool,
         buffer_header_height: u8,
         excerpt_header_height: u8,
+        excerpt_footer_height: u8,
     ) -> Self {
         let row_count = wrap_snapshot.max_point().row() + 1;
         let map = Self {
@@ -179,8 +231,10 @@ impl BlockMap {
             blocks: Vec::new(),
             transforms: RefCell::new(SumTree::from_item(Transform::isomorphic(row_count), &())),
             wrap_snapshot: RefCell::new(wrap_snapshot.clone()),
+            show_excerpt_controls,
             buffer_header_height,
             excerpt_header_height,
+            excerpt_footer_height,
         };
         map.sync(
             &wrap_snapshot,
@@ -364,49 +418,20 @@ impl BlockMap {
                         (position.row(), TransformBlock::Custom(block.clone()))
                     }),
             );
+
             if buffer.show_headers() {
-                blocks_in_edit.extend(
-                    buffer
-                        .excerpt_boundaries_in_range((start_bound, end_bound))
-                        .map(|excerpt_boundary| {
-                            (
-                                wrap_snapshot
-                                    .make_wrap_point(
-                                        Point::new(excerpt_boundary.row.0, 0),
-                                        Bias::Left,
-                                    )
-                                    .row(),
-                                TransformBlock::ExcerptHeader {
-                                    id: excerpt_boundary.id,
-                                    buffer: excerpt_boundary.buffer,
-                                    range: excerpt_boundary.range,
-                                    height: if excerpt_boundary.starts_new_buffer {
-                                        self.buffer_header_height
-                                    } else {
-                                        self.excerpt_header_height
-                                    },
-                                    starts_new_buffer: excerpt_boundary.starts_new_buffer,
-                                },
-                            )
-                        }),
-                );
+                blocks_in_edit.extend(BlockMap::header_blocks(
+                    self.show_excerpt_controls,
+                    self.excerpt_footer_height,
+                    self.buffer_header_height,
+                    self.excerpt_header_height,
+                    buffer,
+                    (start_bound, end_bound),
+                    wrap_snapshot,
+                ));
             }
 
-            // Place excerpt headers above custom blocks on the same row.
-            blocks_in_edit.sort_unstable_by(|(row_a, block_a), (row_b, block_b)| {
-                row_a.cmp(row_b).then_with(|| match (block_a, block_b) {
-                    (
-                        TransformBlock::ExcerptHeader { .. },
-                        TransformBlock::ExcerptHeader { .. },
-                    ) => Ordering::Equal,
-                    (TransformBlock::ExcerptHeader { .. }, _) => Ordering::Less,
-                    (_, TransformBlock::ExcerptHeader { .. }) => Ordering::Greater,
-                    (TransformBlock::Custom(block_a), TransformBlock::Custom(block_b)) => block_a
-                        .disposition
-                        .cmp(&block_b.disposition)
-                        .then_with(|| block_a.id.cmp(&block_b.id)),
-                })
-            });
+            BlockMap::sort_blocks(&mut blocks_in_edit);
 
             // For each of these blocks, insert a new isomorphic transform preceding the block,
             // and then insert the block itself.
@@ -449,6 +474,95 @@ impl BlockMap {
             }
         }
     }
+
+    pub fn show_excerpt_controls(&self) -> bool {
+        self.show_excerpt_controls
+    }
+
+    pub fn header_blocks<'a, 'b: 'a, 'c: 'a + 'b, R, T>(
+        show_excerpt_controls: bool,
+        excerpt_footer_height: u8,
+        buffer_header_height: u8,
+        excerpt_header_height: u8,
+        buffer: &'b multi_buffer::MultiBufferSnapshot,
+        range: R,
+        wrap_snapshot: &'c WrapSnapshot,
+    ) -> impl Iterator<Item = (u32, TransformBlock)> + 'b
+    where
+        R: RangeBounds<T>,
+        T: multi_buffer::ToOffset,
+    {
+        buffer
+            .excerpt_boundaries_in_range(range)
+            .flat_map(move |excerpt_boundary| {
+                let wrap_row = wrap_snapshot
+                    .make_wrap_point(Point::new(excerpt_boundary.row.0, 0), Bias::Left)
+                    .row();
+
+                [
+                    show_excerpt_controls
+                        .then(|| {
+                            excerpt_boundary.prev.as_ref().map(|prev| {
+                                (
+                                    wrap_row,
+                                    TransformBlock::ExcerptFooter {
+                                        id: prev.id,
+                                        height: excerpt_footer_height,
+                                        disposition: if excerpt_boundary.next.is_some() {
+                                            BlockDisposition::Above
+                                        } else {
+                                            BlockDisposition::Below
+                                        },
+                                    },
+                                )
+                            })
+                        })
+                        .flatten(),
+                    excerpt_boundary.next.map(|next| {
+                        let starts_new_buffer = excerpt_boundary
+                            .prev
+                            .map_or(true, |prev| prev.buffer_id != next.buffer_id);
+
+                        (
+                            wrap_row,
+                            TransformBlock::ExcerptHeader {
+                                id: next.id,
+                                buffer: next.buffer,
+                                range: next.range,
+                                height: if starts_new_buffer {
+                                    buffer_header_height
+                                } else {
+                                    excerpt_header_height
+                                },
+                                starts_new_buffer,
+                                show_excerpt_controls,
+                            },
+                        )
+                    }),
+                ]
+            })
+            .flatten()
+    }
+
+    pub(crate) fn sort_blocks<B: BlockLike>(blocks: &mut Vec<(u32, B)>) {
+        // Place excerpt headers and footers above custom blocks on the same row
+        blocks.sort_unstable_by(|(row_a, block_a), (row_b, block_b)| {
+            row_a.cmp(row_b).then_with(|| {
+                block_a
+                    .disposition()
+                    .cmp(&block_b.disposition())
+                    .then_with(|| match ((block_a.block_type()), (block_b.block_type())) {
+                        (BlockType::Footer, BlockType::Footer) => Ordering::Equal,
+                        (BlockType::Footer, _) => Ordering::Less,
+                        (_, BlockType::Footer) => Ordering::Greater,
+                        (BlockType::Header, BlockType::Header) => Ordering::Equal,
+                        (BlockType::Header, _) => Ordering::Less,
+                        (_, BlockType::Header) => Ordering::Greater,
+                        (BlockType::Custom(a_id), BlockType::Custom(b_id)) => a_id.cmp(&b_id),
+                    })
+            })
+        });
+    }
 }
 
 fn push_isomorphic(tree: &mut SumTree<Transform>, rows: u32) {
@@ -996,6 +1110,8 @@ fn offset_for_row(s: &str, target: u32) -> (u32, usize) {
 
 #[cfg(test)]
 mod tests {
+    use std::env;
+
     use super::*;
     use crate::display_map::inlay_map::InlayMap;
     use crate::display_map::{fold_map::FoldMap, tab_map::TabMap, wrap_map::WrapMap};
@@ -1003,7 +1119,6 @@ mod tests {
     use multi_buffer::MultiBuffer;
     use rand::prelude::*;
     use settings::SettingsStore;
-    use std::env;
     use util::RandomCharIter;
 
     #[gpui::test]
@@ -1034,7 +1149,7 @@ mod tests {
         let (mut tab_map, tab_snapshot) = TabMap::new(fold_snapshot, 1.try_into().unwrap());
         let (wrap_map, wraps_snapshot) =
             cx.update(|cx| WrapMap::new(tab_snapshot, font("Helvetica"), px(14.0), None, cx));
-        let mut block_map = BlockMap::new(wraps_snapshot.clone(), 1, 1);
+        let mut block_map = BlockMap::new(wraps_snapshot.clone(), true, 1, 1, 1);
 
         let mut writer = block_map.write(wraps_snapshot.clone(), Default::default());
         let block_ids = writer.insert(vec![
@@ -1206,7 +1321,7 @@ mod tests {
         let (_, wraps_snapshot) = cx.update(|cx| {
             WrapMap::new(tab_snapshot, font("Helvetica"), px(14.0), Some(px(60.)), cx)
         });
-        let mut block_map = BlockMap::new(wraps_snapshot.clone(), 1, 1);
+        let mut block_map = BlockMap::new(wraps_snapshot.clone(), true, 1, 1, 0);
 
         let mut writer = block_map.write(wraps_snapshot.clone(), Default::default());
         writer.insert(vec![
@@ -1252,9 +1367,11 @@ mod tests {
         let font_size = px(14.0);
         let buffer_start_header_height = rng.gen_range(1..=5);
         let excerpt_header_height = rng.gen_range(1..=5);
+        let excerpt_footer_height = rng.gen_range(1..=5);
 
         log::info!("Wrap width: {:?}", wrap_width);
         log::info!("Excerpt Header Height: {:?}", excerpt_header_height);
+        log::info!("Excerpt Footer Height: {:?}", excerpt_footer_height);
 
         let buffer = if rng.gen() {
             let len = rng.gen_range(0..10);
@@ -1273,8 +1390,10 @@ mod tests {
             .update(|cx| WrapMap::new(tab_snapshot, font("Helvetica"), font_size, wrap_width, cx));
         let mut block_map = BlockMap::new(
             wraps_snapshot,
+            true,
             buffer_start_header_height,
             excerpt_header_height,
+            excerpt_footer_height,
         );
         let mut custom_blocks = Vec::new();
 
@@ -1410,24 +1529,23 @@ mod tests {
                     },
                 )
             }));
-            expected_blocks.extend(buffer_snapshot.excerpt_boundaries_in_range(0..).map(
-                |boundary| {
-                    let position =
-                        wraps_snapshot.make_wrap_point(Point::new(boundary.row.0, 0), Bias::Left);
-                    (
-                        position.row(),
-                        ExpectedBlock::ExcerptHeader {
-                            height: if boundary.starts_new_buffer {
-                                buffer_start_header_height
-                            } else {
-                                excerpt_header_height
-                            },
-                            starts_new_buffer: boundary.starts_new_buffer,
-                        },
-                    )
-                },
-            ));
-            expected_blocks.sort_unstable();
+
+            // Note that this needs to be synced with the related section in BlockMap::sync
+            expected_blocks.extend(
+                BlockMap::header_blocks(
+                    true,
+                    excerpt_footer_height,
+                    buffer_start_header_height,
+                    excerpt_header_height,
+                    &buffer_snapshot,
+                    0..,
+                    &wraps_snapshot,
+                )
+                .map(|(row, block)| (row, block.into())),
+            );
+
+            BlockMap::sort_blocks(&mut expected_blocks);
+
             let mut sorted_blocks_iter = expected_blocks.into_iter().peekable();
 
             let input_buffer_rows = buffer_snapshot
@@ -1593,12 +1711,16 @@ mod tests {
             }
         }
 
-        #[derive(Debug, Eq, PartialEq, Ord, PartialOrd)]
+        #[derive(Debug, Eq, PartialEq)]
         enum ExpectedBlock {
             ExcerptHeader {
                 height: u8,
                 starts_new_buffer: bool,
             },
+            ExcerptFooter {
+                height: u8,
+                disposition: BlockDisposition,
+            },
             Custom {
                 disposition: BlockDisposition,
                 id: BlockId,
@@ -1606,11 +1728,26 @@ mod tests {
             },
         }
 
+        impl BlockLike for ExpectedBlock {
+            fn block_type(&self) -> BlockType {
+                match self {
+                    ExpectedBlock::Custom { id, .. } => BlockType::Custom(*id),
+                    ExpectedBlock::ExcerptHeader { .. } => BlockType::Header,
+                    ExpectedBlock::ExcerptFooter { .. } => BlockType::Footer,
+                }
+            }
+
+            fn disposition(&self) -> BlockDisposition {
+                self.disposition()
+            }
+        }
+
         impl ExpectedBlock {
             fn height(&self) -> u8 {
                 match self {
                     ExpectedBlock::ExcerptHeader { height, .. } => *height,
                     ExpectedBlock::Custom { height, .. } => *height,
+                    ExpectedBlock::ExcerptFooter { height, .. } => *height,
                 }
             }
 
@@ -1618,6 +1755,7 @@ mod tests {
                 match self {
                     ExpectedBlock::ExcerptHeader { .. } => BlockDisposition::Above,
                     ExpectedBlock::Custom { disposition, .. } => *disposition,
+                    ExpectedBlock::ExcerptFooter { disposition, .. } => *disposition,
                 }
             }
         }
@@ -1638,6 +1776,14 @@ mod tests {
                         height,
                         starts_new_buffer,
                     },
+                    TransformBlock::ExcerptFooter {
+                        height,
+                        disposition,
+                        ..
+                    } => ExpectedBlock::ExcerptFooter {
+                        height,
+                        disposition,
+                    },
                 }
             }
         }
@@ -1654,6 +1800,7 @@ mod tests {
             match self {
                 TransformBlock::Custom(block) => Some(block),
                 TransformBlock::ExcerptHeader { .. } => None,
+                TransformBlock::ExcerptFooter { .. } => None,
             }
         }
     }

crates/editor/src/editor.rs 🔗

@@ -100,7 +100,7 @@ pub use multi_buffer::{
     Anchor, AnchorRangeExt, ExcerptId, ExcerptRange, MultiBuffer, MultiBufferSnapshot, ToOffset,
     ToPoint,
 };
-use multi_buffer::{MultiBufferPoint, MultiBufferRow, ToOffsetUtf16};
+use multi_buffer::{ExpandExcerptDirection, MultiBufferPoint, MultiBufferRow, ToOffsetUtf16};
 use ordered_float::OrderedFloat;
 use parking_lot::{Mutex, RwLock};
 use project::project_settings::{GitGutterSetting, ProjectSettings};
@@ -1529,19 +1529,25 @@ impl Editor {
     pub fn single_line(cx: &mut ViewContext<Self>) -> Self {
         let buffer = cx.new_model(|cx| Buffer::local("", cx));
         let buffer = cx.new_model(|cx| MultiBuffer::singleton(buffer, cx));
-        Self::new(EditorMode::SingleLine, buffer, None, cx)
+        Self::new(EditorMode::SingleLine, buffer, None, false, cx)
     }
 
     pub fn multi_line(cx: &mut ViewContext<Self>) -> Self {
         let buffer = cx.new_model(|cx| Buffer::local("", cx));
         let buffer = cx.new_model(|cx| MultiBuffer::singleton(buffer, cx));
-        Self::new(EditorMode::Full, buffer, None, cx)
+        Self::new(EditorMode::Full, buffer, None, false, cx)
     }
 
     pub fn auto_height(max_lines: usize, cx: &mut ViewContext<Self>) -> Self {
         let buffer = cx.new_model(|cx| Buffer::local("", cx));
         let buffer = cx.new_model(|cx| MultiBuffer::singleton(buffer, cx));
-        Self::new(EditorMode::AutoHeight { max_lines }, buffer, None, cx)
+        Self::new(
+            EditorMode::AutoHeight { max_lines },
+            buffer,
+            None,
+            false,
+            cx,
+        )
     }
 
     pub fn for_buffer(
@@ -1550,19 +1556,27 @@ impl Editor {
         cx: &mut ViewContext<Self>,
     ) -> Self {
         let buffer = cx.new_model(|cx| MultiBuffer::singleton(buffer, cx));
-        Self::new(EditorMode::Full, buffer, project, cx)
+        Self::new(EditorMode::Full, buffer, project, false, cx)
     }
 
     pub fn for_multibuffer(
         buffer: Model<MultiBuffer>,
         project: Option<Model<Project>>,
+        show_excerpt_controls: bool,
         cx: &mut ViewContext<Self>,
     ) -> Self {
-        Self::new(EditorMode::Full, buffer, project, cx)
+        Self::new(EditorMode::Full, buffer, project, show_excerpt_controls, cx)
     }
 
     pub fn clone(&self, cx: &mut ViewContext<Self>) -> Self {
-        let mut clone = Self::new(self.mode, self.buffer.clone(), self.project.clone(), cx);
+        let show_excerpt_controls = self.display_map.read(cx).show_excerpt_controls();
+        let mut clone = Self::new(
+            self.mode,
+            self.buffer.clone(),
+            self.project.clone(),
+            show_excerpt_controls,
+            cx,
+        );
         self.display_map.update(cx, |display_map, cx| {
             let snapshot = display_map.snapshot(cx);
             clone.display_map.update(cx, |display_map, cx| {
@@ -1579,6 +1593,7 @@ impl Editor {
         mode: EditorMode,
         buffer: Model<MultiBuffer>,
         project: Option<Model<Project>>,
+        show_excerpt_controls: bool,
         cx: &mut ViewContext<Self>,
     ) -> Self {
         let style = cx.text_style();
@@ -1615,12 +1630,16 @@ impl Editor {
             }),
         };
         let display_map = cx.new_model(|cx| {
+            let file_header_size = if show_excerpt_controls { 3 } else { 2 };
+
             DisplayMap::new(
                 buffer.clone(),
                 style.font(),
                 font_size,
                 None,
-                2,
+                show_excerpt_controls,
+                file_header_size,
+                1,
                 1,
                 fold_placeholder,
                 cx,
@@ -4287,7 +4306,7 @@ impl Editor {
         workspace.update(&mut cx, |workspace, cx| {
             let project = workspace.project().clone();
             let editor =
-                cx.new_view(|cx| Editor::for_multibuffer(excerpt_buffer, Some(project), cx));
+                cx.new_view(|cx| Editor::for_multibuffer(excerpt_buffer, Some(project), true, cx));
             workspace.add_item_to_active_pane(Box::new(editor.clone()), None, cx);
             editor.update(cx, |editor, cx| {
                 editor.highlight_background::<Self>(
@@ -8127,9 +8146,34 @@ impl Editor {
     }
 
     pub fn expand_excerpts(&mut self, action: &ExpandExcerpts, cx: &mut ViewContext<Self>) {
+        self.expand_excerpts_for_direction(action.lines, ExpandExcerptDirection::UpAndDown, cx)
+    }
+
+    pub fn expand_excerpts_down(
+        &mut self,
+        action: &ExpandExcerptsDown,
+        cx: &mut ViewContext<Self>,
+    ) {
+        self.expand_excerpts_for_direction(action.lines, ExpandExcerptDirection::Down, cx)
+    }
+
+    pub fn expand_excerpts_up(&mut self, action: &ExpandExcerptsUp, cx: &mut ViewContext<Self>) {
+        self.expand_excerpts_for_direction(action.lines, ExpandExcerptDirection::Up, cx)
+    }
+
+    pub fn expand_excerpts_for_direction(
+        &mut self,
+        lines: u32,
+        direction: ExpandExcerptDirection,
+        cx: &mut ViewContext<Self>,
+    ) {
         let selections = self.selections.disjoint_anchors();
 
-        let lines = if action.lines == 0 { 3 } else { action.lines };
+        let lines = if lines == 0 {
+            EditorSettings::get_global(cx).expand_excerpt_lines
+        } else {
+            lines
+        };
 
         self.buffer.update(cx, |buffer, cx| {
             buffer.expand_excerpts(
@@ -8138,14 +8182,22 @@ impl Editor {
                     .map(|selection| selection.head().excerpt_id)
                     .dedup(),
                 lines,
+                direction,
                 cx,
             )
         })
     }
 
-    pub fn expand_excerpt(&mut self, excerpt: ExcerptId, cx: &mut ViewContext<Self>) {
-        self.buffer
-            .update(cx, |buffer, cx| buffer.expand_excerpts([excerpt], 3, cx))
+    pub fn expand_excerpt(
+        &mut self,
+        excerpt: ExcerptId,
+        direction: ExpandExcerptDirection,
+        cx: &mut ViewContext<Self>,
+    ) {
+        let lines = EditorSettings::get_global(cx).expand_excerpt_lines;
+        self.buffer.update(cx, |buffer, cx| {
+            buffer.expand_excerpts([excerpt], lines, direction, cx)
+        })
     }
 
     fn go_to_diagnostic(&mut self, _: &GoToDiagnostic, cx: &mut ViewContext<Self>) {
@@ -8792,7 +8844,7 @@ impl Editor {
         });
 
         let editor = cx.new_view(|cx| {
-            Editor::for_multibuffer(excerpt_buffer, Some(workspace.project().clone()), cx)
+            Editor::for_multibuffer(excerpt_buffer, Some(workspace.project().clone()), true, cx)
         });
         editor.update(cx, |editor, cx| {
             editor.highlight_background::<Self>(

crates/editor/src/editor_settings.rs 🔗

@@ -21,6 +21,7 @@ pub struct EditorSettings {
     pub seed_search_query_from_cursor: SeedQuerySetting,
     pub multi_cursor_modifier: MultiCursorModifier,
     pub redact_private_values: bool,
+    pub expand_excerpt_lines: u32,
     #[serde(default)]
     pub double_click_in_multibuffer: DoubleClickInMultibuffer,
 }
@@ -182,6 +183,11 @@ pub struct EditorSettingsContent {
     /// Default: false
     pub redact_private_values: Option<bool>,
 
+    /// How many lines to expand the multibuffer excerpts by default
+    ///
+    /// Default: 3
+    pub expand_excerpt_lines: Option<u32>,
+
     /// What to do when multibuffer is double clicked in some of its excerpts
     /// (parts of singleton buffers).
     ///

crates/editor/src/editor_tests.rs 🔗

@@ -4292,10 +4292,10 @@ async fn test_select_previous_multibuffer(cx: &mut gpui::TestAppContext) {
     let mut cx = EditorTestContext::new_multibuffer(
         cx,
         [
-            indoc! {
+            &indoc! {
                 "aaa\n«bbb\nccc\n»ddd"
             },
-            indoc! {
+            &indoc! {
                 "aaa\n«bbb\nccc\n»ddd"
             },
         ],
@@ -6033,8 +6033,15 @@ async fn test_multibuffer_format_during_save(cx: &mut gpui::TestAppContext) {
         );
         multi_buffer
     });
-    let multi_buffer_editor =
-        cx.new_view(|cx| Editor::new(EditorMode::Full, multi_buffer, Some(project.clone()), cx));
+    let multi_buffer_editor = cx.new_view(|cx| {
+        Editor::new(
+            EditorMode::Full,
+            multi_buffer,
+            Some(project.clone()),
+            true,
+            cx,
+        )
+    });
 
     multi_buffer_editor.update(cx, |editor, cx| {
         editor.change_selections(Some(Autoscroll::Next), cx, |s| s.select_ranges(Some(1..2)));
@@ -9430,8 +9437,15 @@ async fn test_mutlibuffer_in_navigation_history(cx: &mut gpui::TestAppContext) {
     let project = Project::test(fs, ["/a".as_ref()], cx).await;
     let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
     let cx = &mut VisualTestContext::from_window(*workspace.deref(), cx);
-    let multi_buffer_editor =
-        cx.new_view(|cx| Editor::new(EditorMode::Full, multi_buffer, Some(project.clone()), cx));
+    let multi_buffer_editor = cx.new_view(|cx| {
+        Editor::new(
+            EditorMode::Full,
+            multi_buffer,
+            Some(project.clone()),
+            true,
+            cx,
+        )
+    });
     let multibuffer_item_id = workspace
         .update(cx, |workspace, cx| {
             assert!(
@@ -10358,42 +10372,49 @@ async fn test_toggle_diff_expand_in_multi_buffer(cx: &mut gpui::TestAppContext)
     let project = Project::test(fs, ["/a".as_ref()], cx).await;
     let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx));
     let cx = &mut VisualTestContext::from_window(*workspace.deref(), cx);
-    let multi_buffer_editor =
-        cx.new_view(|cx| Editor::new(EditorMode::Full, multi_buffer, Some(project.clone()), cx));
+    let multi_buffer_editor = cx.new_view(|cx| {
+        Editor::new(
+            EditorMode::Full,
+            multi_buffer,
+            Some(project.clone()),
+            true,
+            cx,
+        )
+    });
     cx.executor().run_until_parked();
 
     let expected_all_hunks = vec![
         (
             "bbbb\n".to_string(),
             DiffHunkStatus::Removed,
-            DisplayRow(3)..DisplayRow(3),
+            DisplayRow(4)..DisplayRow(4),
         ),
         (
             "nnnn\n".to_string(),
             DiffHunkStatus::Modified,
-            DisplayRow(16)..DisplayRow(17),
+            DisplayRow(21)..DisplayRow(22),
         ),
         (
             "".to_string(),
             DiffHunkStatus::Added,
-            DisplayRow(31)..DisplayRow(32),
+            DisplayRow(41)..DisplayRow(42),
         ),
     ];
     let expected_all_hunks_shifted = vec![
         (
             "bbbb\n".to_string(),
             DiffHunkStatus::Removed,
-            DisplayRow(4)..DisplayRow(4),
+            DisplayRow(5)..DisplayRow(5),
         ),
         (
             "nnnn\n".to_string(),
             DiffHunkStatus::Modified,
-            DisplayRow(18)..DisplayRow(19),
+            DisplayRow(23)..DisplayRow(24),
         ),
         (
             "".to_string(),
             DiffHunkStatus::Added,
-            DisplayRow(33)..DisplayRow(34),
+            DisplayRow(43)..DisplayRow(44),
         ),
     ];
 
@@ -10418,8 +10439,8 @@ async fn test_toggle_diff_expand_in_multi_buffer(cx: &mut gpui::TestAppContext)
         assert_eq!(
             expanded_hunks_background_highlights(editor, cx),
             vec![
-                DisplayRow(18)..=DisplayRow(18),
-                DisplayRow(33)..=DisplayRow(33)
+                DisplayRow(23)..=DisplayRow(23),
+                DisplayRow(43)..=DisplayRow(43)
             ],
         );
         assert_eq!(all_hunks, expected_all_hunks_shifted);
@@ -10450,8 +10471,8 @@ async fn test_toggle_diff_expand_in_multi_buffer(cx: &mut gpui::TestAppContext)
         assert_eq!(
             expanded_hunks_background_highlights(editor, cx),
             vec![
-                DisplayRow(18)..=DisplayRow(18),
-                DisplayRow(33)..=DisplayRow(33)
+                DisplayRow(23)..=DisplayRow(23),
+                DisplayRow(43)..=DisplayRow(43)
             ],
         );
         assert_eq!(all_hunks, expected_all_hunks_shifted);

crates/editor/src/element.rs 🔗

@@ -30,11 +30,11 @@ use gpui::{
     anchored, deferred, div, fill, outline, point, px, quad, relative, size, svg,
     transparent_black, Action, AnchorCorner, AnyElement, AvailableSpace, Bounds, ClipboardItem,
     ContentMask, Corners, CursorStyle, DispatchPhase, Edges, Element, ElementInputHandler, Entity,
-    FontId, GlobalElementId, Hitbox, Hsla, InteractiveElement, IntoElement, ModifiersChangedEvent,
-    MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, ParentElement, Pixels,
-    ScrollDelta, ScrollWheelEvent, ShapedLine, SharedString, Size, StatefulInteractiveElement,
-    Style, Styled, TextRun, TextStyle, TextStyleRefinement, View, ViewContext, WeakView,
-    WindowContext,
+    FontId, GlobalElementId, Hitbox, Hsla, InteractiveElement, IntoElement, Length,
+    ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad,
+    ParentElement, Pixels, ScrollDelta, ScrollWheelEvent, ShapedLine, SharedString, Size,
+    StatefulInteractiveElement, Style, Styled, TextRun, TextStyle, TextStyleRefinement, View,
+    ViewContext, WeakView, WindowContext,
 };
 use itertools::Itertools;
 use language::language_settings::{
@@ -278,6 +278,8 @@ impl EditorElement {
         register_action(view, cx, Editor::redo_selection);
         if !view.read(cx).is_singleton(cx) {
             register_action(view, cx, Editor::expand_excerpts);
+            register_action(view, cx, Editor::expand_excerpts_up);
+            register_action(view, cx, Editor::expand_excerpts_down);
         }
         register_action(view, cx, Editor::go_to_diagnostic);
         register_action(view, cx, Editor::go_to_prev_diagnostic);
@@ -1893,6 +1895,7 @@ impl EditorElement {
             .partition::<Vec<_>, _>(|(_, block)| match block {
                 TransformBlock::ExcerptHeader { .. } => false,
                 TransformBlock::Custom(block) => block.style() == BlockStyle::Fixed,
+                TransformBlock::ExcerptFooter { .. } => false,
             });
 
         let render_block = |block: &TransformBlock,
@@ -1933,6 +1936,7 @@ impl EditorElement {
                     starts_new_buffer,
                     height,
                     id,
+                    show_excerpt_controls,
                     ..
                 } => {
                     let include_root = self
@@ -1986,6 +1990,9 @@ impl EditorElement {
                         }
                     });
 
+                    let icon_offset = gutter_dimensions.width
+                        - (gutter_dimensions.left_padding + gutter_dimensions.margin);
+
                     let element = if *starts_new_buffer {
                         let path = buffer.resolve_file_path(cx, include_root);
                         let mut filename = None;
@@ -1998,15 +2005,16 @@ impl EditorElement {
                                 .map(|p| SharedString::from(p.to_string_lossy().to_string() + "/"));
                         }
 
+                        let header_padding = px(6.0);
+
                         v_flex()
-                            .id(("path header container", block_id))
+                            .id(("path excerpt header", block_id))
                             .size_full()
-                            .justify_center()
-                            .p(gpui::px(6.))
+                            .p(header_padding)
                             .child(
                                 h_flex()
+                                    .flex_basis(Length::Definite(DefiniteLength::Fraction(0.667)))
                                     .id("path header block")
-                                    .size_full()
                                     .pl(gpui::px(12.))
                                     .pr(gpui::px(8.))
                                     .rounded_md()
@@ -2059,42 +2067,19 @@ impl EditorElement {
                                             }))
                                     }),
                             )
-                    } else {
-                        v_flex()
-                            .id(("collapsed context", block_id))
-                            .size_full()
-                            .child(
-                                div()
-                                    .flex()
-                                    .v_flex()
-                                    .justify_start()
-                                    .id("jump to collapsed context")
-                                    .w(relative(1.0))
-                                    .h_full()
-                                    .child(
-                                        div()
-                                            .h_px()
-                                            .w_full()
-                                            .bg(cx.theme().colors().border_variant)
-                                            .group_hover("excerpt-jump-action", |style| {
-                                                style.bg(cx.theme().colors().border)
-                                            }),
-                                    ),
-                            )
-                            .child(
+                            .children(show_excerpt_controls.then(|| {
                                 h_flex()
+                                    .flex_basis(Length::Definite(DefiniteLength::Fraction(0.333)))
+                                    .pt_1()
                                     .justify_end()
                                     .flex_none()
-                                    .w(
-                                        gutter_dimensions.width - (gutter_dimensions.left_padding), // + gutter_dimensions.right_padding)
-                                    )
-                                    .h_full()
+                                    .w(icon_offset - header_padding)
                                     .child(
                                         ButtonLike::new("expand-icon")
                                             .style(ButtonStyle::Transparent)
                                             .child(
                                                 svg()
-                                                    .path(IconName::ExpandVertical.path())
+                                                    .path(IconName::ArrowUpFromLine.path())
                                                     .size(IconSize::XSmall.rems())
                                                     .text_color(
                                                         cx.theme().colors().editor_line_number,
@@ -2111,7 +2096,11 @@ impl EditorElement {
                                             .on_click(cx.listener_for(&self.editor, {
                                                 let id = *id;
                                                 move |editor, _, cx| {
-                                                    editor.expand_excerpt(id, cx);
+                                                    editor.expand_excerpt(
+                                                        id,
+                                                        multi_buffer::ExpandExcerptDirection::Up,
+                                                        cx,
+                                                    );
                                                 }
                                             }))
                                             .tooltip({
@@ -2123,6 +2112,122 @@ impl EditorElement {
                                                     )
                                                 }
                                             }),
+                                    )
+                            }))
+                    } else {
+                        v_flex()
+                            .id(("excerpt header", block_id))
+                            .size_full()
+                            .child(
+                                div()
+                                    .flex()
+                                    .v_flex()
+                                    .justify_start()
+                                    .id("jump to collapsed context")
+                                    .w(relative(1.0))
+                                    .h_full()
+                                    .child(
+                                        div()
+                                            .h_px()
+                                            .w_full()
+                                            .bg(cx.theme().colors().border_variant)
+                                            .group_hover("excerpt-jump-action", |style| {
+                                                style.bg(cx.theme().colors().border)
+                                            }),
+                                    ),
+                            )
+                            .child(
+                                h_flex()
+                                    .justify_end()
+                                    .flex_none()
+                                    .w(icon_offset)
+                                    .h_full()
+                                    .child(
+                                        show_excerpt_controls.then(|| {
+                                            ButtonLike::new("expand-icon")
+                                                .style(ButtonStyle::Transparent)
+                                                .child(
+                                                    svg()
+                                                        .path(IconName::ArrowUpFromLine.path())
+                                                        .size(IconSize::XSmall.rems())
+                                                        .text_color(
+                                                            cx.theme().colors().editor_line_number,
+                                                        )
+                                                        .group("")
+                                                        .hover(|style| {
+                                                            style.text_color(
+                                                                cx.theme()
+                                                                    .colors()
+                                                                    .editor_active_line_number,
+                                                            )
+                                                        }),
+                                                )
+                                                .on_click(cx.listener_for(&self.editor, {
+                                                    let id = *id;
+                                                    move |editor, _, cx| {
+                                                        editor.expand_excerpt(
+                                                            id,
+                                                            multi_buffer::ExpandExcerptDirection::Up,
+                                                            cx,
+                                                        );
+                                                    }
+                                                }))
+                                                .tooltip({
+                                                    move |cx| {
+                                                        Tooltip::for_action(
+                                                            "Expand Excerpt",
+                                                            &ExpandExcerpts { lines: 0 },
+                                                            cx,
+                                                        )
+                                                    }
+                                                })
+                                        }).unwrap_or_else(|| {
+                                            ButtonLike::new("jump-icon")
+                                                .style(ButtonStyle::Transparent)
+                                                .child(
+                                                    svg()
+                                                        .path(IconName::ArrowUpRight.path())
+                                                        .size(IconSize::XSmall.rems())
+                                                        .text_color(
+                                                            cx.theme().colors().border_variant,
+                                                        )
+                                                        .group("excerpt-jump-action")
+                                                        .group_hover("excerpt-jump-action", |style| {
+                                                            style.text_color(
+                                                                cx.theme().colors().border
+
+                                                            )
+                                                        })
+                                                )
+                                                .when_some(jump_data.clone(), |this, jump_data| {
+                                                    this.on_click(cx.listener_for(&self.editor, {
+                                                        let path = jump_data.path.clone();
+                                                        move |editor, _, cx| {
+                                                            cx.stop_propagation();
+
+                                                            editor.jump(
+                                                                path.clone(),
+                                                                jump_data.position,
+                                                                jump_data.anchor,
+                                                                jump_data.line_offset_from_top,
+                                                                cx,
+                                                            );
+                                                        }
+                                                    }))
+                                                    .tooltip(move |cx| {
+                                                        Tooltip::for_action(
+                                                            format!(
+                                                                "Jump to {}:L{}",
+                                                                jump_data.path.path.display(),
+                                                                jump_data.position.row + 1
+                                                            ),
+                                                            &OpenExcerpts,
+                                                            cx,
+                                                        )
+                                                    })
+                                                })
+                                        })
+
                                     ),
                             )
                             .group("excerpt-jump-action")
@@ -2157,6 +2262,53 @@ impl EditorElement {
                     };
                     element.into_any()
                 }
+
+                TransformBlock::ExcerptFooter { id, .. } => {
+                    let element = v_flex().id(("excerpt footer", block_id)).size_full().child(
+                        h_flex()
+                            .justify_end()
+                            .flex_none()
+                            .w(gutter_dimensions.width
+                                - (gutter_dimensions.left_padding + gutter_dimensions.margin))
+                            .h_full()
+                            .child(
+                                ButtonLike::new("expand-icon")
+                                    .style(ButtonStyle::Transparent)
+                                    .child(
+                                        svg()
+                                            .path(IconName::ArrowDownFromLine.path())
+                                            .size(IconSize::XSmall.rems())
+                                            .text_color(cx.theme().colors().editor_line_number)
+                                            .group("")
+                                            .hover(|style| {
+                                                style.text_color(
+                                                    cx.theme().colors().editor_active_line_number,
+                                                )
+                                            }),
+                                    )
+                                    .on_click(cx.listener_for(&self.editor, {
+                                        let id = *id;
+                                        move |editor, _, cx| {
+                                            editor.expand_excerpt(
+                                                id,
+                                                multi_buffer::ExpandExcerptDirection::Down,
+                                                cx,
+                                            );
+                                        }
+                                    }))
+                                    .tooltip({
+                                        move |cx| {
+                                            Tooltip::for_action(
+                                                "Expand Excerpt",
+                                                &ExpandExcerpts { lines: 0 },
+                                                cx,
+                                            )
+                                        }
+                                    }),
+                            ),
+                    );
+                    element.into_any()
+                }
             };
 
             let size = element.layout_as_root(available_space, cx);
@@ -2184,6 +2336,7 @@ impl EditorElement {
             let style = match block {
                 TransformBlock::Custom(block) => block.style(),
                 TransformBlock::ExcerptHeader { .. } => BlockStyle::Sticky,
+                TransformBlock::ExcerptFooter { .. } => BlockStyle::Sticky,
             };
             let width = match style {
                 BlockStyle::Sticky => hitbox.size.width,
@@ -5413,7 +5566,7 @@ mod tests {
         init_test(cx, |_| {});
         let window = cx.add_window(|cx| {
             let buffer = MultiBuffer::build_simple(&sample_text(6, 6, 'a'), cx);
-            Editor::new(EditorMode::Full, buffer, None, cx)
+            Editor::new(EditorMode::Full, buffer, None, true, cx)
         });
 
         let editor = window.root(cx).unwrap();
@@ -5491,7 +5644,7 @@ mod tests {
 
         let window = cx.add_window(|cx| {
             let buffer = MultiBuffer::build_simple(&(sample_text(6, 6, 'a') + "\n"), cx);
-            Editor::new(EditorMode::Full, buffer, None, cx)
+            Editor::new(EditorMode::Full, buffer, None, true, cx)
         });
         let cx = &mut VisualTestContext::from_window(*window, cx);
         let editor = window.root(cx).unwrap();
@@ -5556,21 +5709,26 @@ mod tests {
         // multi-buffer support
         // in DisplayPoint coordinates, this is what we're dealing with:
         //  0: [[file
-        //  1:   header]]
-        //  2: aaaaaa
-        //  3: bbbbbb
-        //  4: cccccc
-        //  5:
-        //  6: ...
-        //  7: ffffff
-        //  8: gggggg
-        //  9: hhhhhh
-        // 10:
-        // 11: [[file
-        // 12:   header]]
-        // 13: bbbbbb
-        // 14: cccccc
-        // 15: dddddd
+        //  1:   header
+        //  2:   section]]
+        //  3: aaaaaa
+        //  4: bbbbbb
+        //  5: cccccc
+        //  6:
+        //  7: [[footer]]
+        //  8: [[header]]
+        //  9: ffffff
+        // 10: gggggg
+        // 11: hhhhhh
+        // 12:
+        // 13: [[footer]]
+        // 14: [[file
+        // 15:   header
+        // 16:   section]]
+        // 17: bbbbbb
+        // 18: cccccc
+        // 19: dddddd
+        // 20: [[footer]]
         let window = cx.add_window(|cx| {
             let buffer = MultiBuffer::build_multi(
                 [
@@ -5588,7 +5746,7 @@ mod tests {
                 ],
                 cx,
             );
-            Editor::new(EditorMode::Full, buffer, None, cx)
+            Editor::new(EditorMode::Full, buffer, None, true, cx)
         });
         let editor = window.root(cx).unwrap();
         let style = cx.update(|cx| editor.read(cx).style().unwrap().clone());
@@ -5613,21 +5771,21 @@ mod tests {
         // and doesn't allow selection to bleed through
         assert_eq!(
             local_selections[0].range,
-            DisplayPoint::new(DisplayRow(4), 0)..DisplayPoint::new(DisplayRow(6), 0)
+            DisplayPoint::new(DisplayRow(4), 0)..DisplayPoint::new(DisplayRow(7), 0)
         );
         assert_eq!(
             local_selections[0].head,
-            DisplayPoint::new(DisplayRow(5), 0)
+            DisplayPoint::new(DisplayRow(6), 0)
         );
         // moves cursor on buffer boundary back two lines
         // and doesn't allow selection to bleed through
         assert_eq!(
             local_selections[1].range,
-            DisplayPoint::new(DisplayRow(10), 0)..DisplayPoint::new(DisplayRow(11), 0)
+            DisplayPoint::new(DisplayRow(10), 0)..DisplayPoint::new(DisplayRow(13), 0)
         );
         assert_eq!(
             local_selections[1].head,
-            DisplayPoint::new(DisplayRow(10), 0)
+            DisplayPoint::new(DisplayRow(12), 0)
         );
     }
 
@@ -5637,7 +5795,7 @@ mod tests {
 
         let window = cx.add_window(|cx| {
             let buffer = MultiBuffer::build_simple("", cx);
-            Editor::new(EditorMode::Full, buffer, None, cx)
+            Editor::new(EditorMode::Full, buffer, None, true, cx)
         });
         let cx = &mut VisualTestContext::from_window(*window, cx);
         let editor = window.root(cx).unwrap();
@@ -5835,7 +5993,7 @@ mod tests {
         );
         let window = cx.add_window(|cx| {
             let buffer = MultiBuffer::build_simple(&input_text, cx);
-            Editor::new(editor_mode, buffer, None, cx)
+            Editor::new(editor_mode, buffer, None, true, cx)
         });
         let cx = &mut VisualTestContext::from_window(*window, cx);
         let editor = window.root(cx).unwrap();

crates/editor/src/hunk_diff.rs 🔗

@@ -572,7 +572,7 @@ fn editor_with_deleted_text(
             );
         });
 
-        let mut editor = Editor::for_multibuffer(multi_buffer, None, cx);
+        let mut editor = Editor::for_multibuffer(multi_buffer, None, true, cx);
         editor.soft_wrap_mode_override = Some(language::language_settings::SoftWrap::None);
         editor.show_wrap_guides = Some(false);
         editor.show_gutter = false;

crates/editor/src/inlay_hint_cache.rs 🔗

@@ -2662,8 +2662,8 @@ pub mod tests {
         });
 
         cx.executor().run_until_parked();
-        let editor =
-            cx.add_window(|cx| Editor::for_multibuffer(multibuffer, Some(project.clone()), cx));
+        let editor = cx
+            .add_window(|cx| Editor::for_multibuffer(multibuffer, Some(project.clone()), true, cx));
 
         let editor_edited = Arc::new(AtomicBool::new(false));
         let fake_server = fake_servers.next().await.unwrap();
@@ -2871,6 +2871,7 @@ pub mod tests {
                 "main hint #5".to_string(),
                 "other hint(edited) #0".to_string(),
                 "other hint(edited) #1".to_string(),
+                "other hint(edited) #2".to_string(),
             ];
             assert_eq!(
                 expected_hints,
@@ -2881,8 +2882,8 @@ pub mod tests {
             assert_eq!(expected_hints, visible_hint_labels(editor, cx));
 
             let current_cache_version = editor.inlay_hint_cache().version;
-            // We expect two new hints for the excerpts from `other.rs`:
-            let expected_version = last_scroll_update_version + 2;
+            // We expect three new hints for the excerpts from `other.rs`:
+            let expected_version = last_scroll_update_version + 3;
             assert_eq!(
                 current_cache_version,
                 expected_version,
@@ -2970,8 +2971,8 @@ pub mod tests {
         assert!(!buffer_2_excerpts.is_empty());
 
         cx.executor().run_until_parked();
-        let editor =
-            cx.add_window(|cx| Editor::for_multibuffer(multibuffer, Some(project.clone()), cx));
+        let editor = cx
+            .add_window(|cx| Editor::for_multibuffer(multibuffer, Some(project.clone()), true, cx));
         let editor_edited = Arc::new(AtomicBool::new(false));
         let fake_server = fake_servers.next().await.unwrap();
         let closure_editor_edited = Arc::clone(&editor_edited);

crates/editor/src/items.rs 🔗

@@ -137,7 +137,7 @@ impl FollowableItem for Editor {
 
                     cx.new_view(|cx| {
                         let mut editor =
-                            Editor::for_multibuffer(multibuffer, Some(project.clone()), cx);
+                            Editor::for_multibuffer(multibuffer, Some(project.clone()), true, cx);
                         editor.remote_id = Some(remote_id);
                         editor
                     })
@@ -1162,23 +1162,26 @@ impl SearchableItem for Editor {
                 }
             } else {
                 for excerpt in buffer.excerpt_boundaries_in_range(0..buffer.len()) {
-                    let excerpt_range = excerpt.range.context.to_offset(&excerpt.buffer);
-                    ranges.extend(
-                        query
-                            .search(&excerpt.buffer, Some(excerpt_range.clone()))
-                            .await
-                            .into_iter()
-                            .map(|range| {
-                                let start = excerpt
-                                    .buffer
-                                    .anchor_after(excerpt_range.start + range.start);
-                                let end = excerpt
-                                    .buffer
-                                    .anchor_before(excerpt_range.start + range.end);
-                                buffer.anchor_in_excerpt(excerpt.id, start).unwrap()
-                                    ..buffer.anchor_in_excerpt(excerpt.id, end).unwrap()
-                            }),
-                    );
+                    if let Some(next_excerpt) = excerpt.next {
+                        let excerpt_range =
+                            next_excerpt.range.context.to_offset(&next_excerpt.buffer);
+                        ranges.extend(
+                            query
+                                .search(&next_excerpt.buffer, Some(excerpt_range.clone()))
+                                .await
+                                .into_iter()
+                                .map(|range| {
+                                    let start = next_excerpt
+                                        .buffer
+                                        .anchor_after(excerpt_range.start + range.start);
+                                    let end = next_excerpt
+                                        .buffer
+                                        .anchor_before(excerpt_range.start + range.end);
+                                    buffer.anchor_in_excerpt(next_excerpt.id, start).unwrap()
+                                        ..buffer.anchor_in_excerpt(next_excerpt.id, end).unwrap()
+                                }),
+                        );
+                    }
                 }
             }
             ranges

crates/editor/src/movement.rs 🔗

@@ -695,12 +695,15 @@ mod tests {
         let font_size = px(14.0);
         let buffer = MultiBuffer::build_simple(input_text, cx);
         let buffer_snapshot = buffer.read(cx).snapshot(cx);
+
         let display_map = cx.new_model(|cx| {
             DisplayMap::new(
                 buffer,
                 font,
                 font_size,
                 None,
+                true,
+                1,
                 1,
                 1,
                 FoldPlaceholder::test(),
@@ -917,8 +920,10 @@ mod tests {
                     font,
                     px(14.0),
                     None,
+                    true,
                     2,
                     2,
+                    0,
                     FoldPlaceholder::test(),
                     cx,
                 )

crates/editor/src/rust_analyzer_ext.rs 🔗

@@ -109,7 +109,9 @@ pub fn expand_macro_recursively(
                 MultiBuffer::singleton(buffer, cx).with_title(macro_expansion.name)
             });
             workspace.add_item_to_active_pane(
-                Box::new(cx.new_view(|cx| Editor::for_multibuffer(multibuffer, Some(project), cx))),
+                Box::new(
+                    cx.new_view(|cx| Editor::for_multibuffer(multibuffer, Some(project), true, cx)),
+                ),
                 None,
                 cx,
             );

crates/editor/src/test.rs 🔗

@@ -39,6 +39,8 @@ pub fn marked_display_snapshot(
             font,
             font_size,
             None,
+            true,
+            1,
             1,
             1,
             FoldPlaceholder::test(),
@@ -74,7 +76,7 @@ pub fn assert_text_with_selections(
 #[allow(dead_code)]
 #[cfg(any(test, feature = "test-support"))]
 pub(crate) fn build_editor(buffer: Model<MultiBuffer>, cx: &mut ViewContext<Editor>) -> Editor {
-    Editor::new(EditorMode::Full, buffer, None, cx)
+    Editor::new(EditorMode::Full, buffer, None, true, cx)
 }
 
 pub(crate) fn build_editor_with_project(
@@ -82,7 +84,7 @@ pub(crate) fn build_editor_with_project(
     buffer: Model<MultiBuffer>,
     cx: &mut ViewContext<Editor>,
 ) -> Editor {
-    Editor::new(EditorMode::Full, buffer, Some(project), cx)
+    Editor::new(EditorMode::Full, buffer, Some(project), true, cx)
 }
 
 #[cfg(any(test, feature = "test-support"))]

crates/editor/src/test/editor_test_context.rs 🔗

@@ -22,6 +22,7 @@ use std::{
         Arc,
     },
 };
+
 use ui::Context;
 use util::{
     assert_set_eq,
@@ -149,6 +150,10 @@ impl EditorTestContext {
         self.multibuffer(|buffer, cx| buffer.snapshot(cx).text())
     }
 
+    pub fn display_text(&mut self) -> String {
+        self.update_editor(|editor, cx| editor.display_text(cx))
+    }
+
     pub fn buffer<F, T>(&mut self, read: F) -> T
     where
         F: FnOnce(&Buffer, &AppContext) -> T,

crates/multi_buffer/src/multi_buffer.rs 🔗

@@ -18,6 +18,7 @@ use language::{
 };
 use smallvec::SmallVec;
 use std::{
+    any::type_name,
     borrow::Cow,
     cell::{Ref, RefCell},
     cmp, fmt,
@@ -173,17 +174,40 @@ pub struct MultiBufferSnapshot {
     show_headers: bool,
 }
 
-/// A boundary between [`Excerpt`]s in a [`MultiBuffer`]
-pub struct ExcerptBoundary {
+pub struct ExcerptInfo {
     pub id: ExcerptId,
-    pub row: MultiBufferRow,
     pub buffer: BufferSnapshot,
+    pub buffer_id: BufferId,
     pub range: ExcerptRange<text::Anchor>,
-    /// It's possible to have multiple excerpts in the same buffer,
-    /// and they are rendered together without a new File header.
-    ///
-    /// This flag indicates that the excerpt is the first one in the buffer.
-    pub starts_new_buffer: bool,
+}
+
+impl std::fmt::Debug for ExcerptInfo {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct(type_name::<Self>())
+            .field("id", &self.id)
+            .field("buffer_id", &self.buffer_id)
+            .field("range", &self.range)
+            .finish()
+    }
+}
+
+/// A boundary between [`Excerpt`]s in a [`MultiBuffer`]
+#[derive(Debug)]
+pub struct ExcerptBoundary {
+    pub prev: Option<ExcerptInfo>,
+    pub next: Option<ExcerptInfo>,
+    /// The row in the `MultiBuffer` where the boundary is located
+    pub row: MultiBufferRow,
+}
+
+impl ExcerptBoundary {
+    pub fn starts_new_buffer(&self) -> bool {
+        match (self.prev.as_ref(), self.next.as_ref()) {
+            (None, _) => true,
+            (Some(_), None) => false,
+            (Some(prev), Some(next)) => prev.buffer_id != next.buffer_id,
+        }
+    }
 }
 
 /// A slice into a [`Buffer`] that is being edited in a [`MultiBuffer`].
@@ -281,6 +305,30 @@ struct ExcerptBytes<'a> {
     reversed: bool,
 }
 
+pub enum ExpandExcerptDirection {
+    Up,
+    Down,
+    UpAndDown,
+}
+
+impl ExpandExcerptDirection {
+    pub fn should_expand_up(&self) -> bool {
+        match self {
+            ExpandExcerptDirection::Up => true,
+            ExpandExcerptDirection::Down => false,
+            ExpandExcerptDirection::UpAndDown => true,
+        }
+    }
+
+    pub fn should_expand_down(&self) -> bool {
+        match self {
+            ExpandExcerptDirection::Up => false,
+            ExpandExcerptDirection::Down => true,
+            ExpandExcerptDirection::UpAndDown => true,
+        }
+    }
+}
+
 #[derive(Clone, Debug, PartialEq)]
 pub struct MultiBufferIndentGuide {
     pub multibuffer_row_range: Range<MultiBufferRow>,
@@ -1610,6 +1658,7 @@ impl MultiBuffer {
         &mut self,
         ids: impl IntoIterator<Item = ExcerptId>,
         line_count: u32,
+        direction: ExpandExcerptDirection,
         cx: &mut ModelContext<Self>,
     ) {
         if line_count == 0 {
@@ -1630,26 +1679,40 @@ impl MultiBuffer {
             let mut excerpt = cursor.item().unwrap().clone();
             let old_text_len = excerpt.text_summary.len;
 
+            let up_line_count = if direction.should_expand_up() {
+                line_count
+            } else {
+                0
+            };
+
             let start_row = excerpt
                 .range
                 .context
                 .start
                 .to_point(&excerpt.buffer)
                 .row
-                .saturating_sub(line_count);
+                .saturating_sub(up_line_count);
             let start_point = Point::new(start_row, 0);
             excerpt.range.context.start = excerpt.buffer.anchor_before(start_point);
 
-            let end_point = excerpt.buffer.clip_point(
-                excerpt.range.context.end.to_point(&excerpt.buffer) + Point::new(line_count, 0),
+            let down_line_count = if direction.should_expand_down() {
+                line_count
+            } else {
+                0
+            };
+
+            let mut end_point = excerpt.buffer.clip_point(
+                excerpt.range.context.end.to_point(&excerpt.buffer)
+                    + Point::new(down_line_count, 0),
                 Bias::Left,
             );
+            end_point.column = excerpt.buffer.line_len(end_point.row);
             excerpt.range.context.end = excerpt.buffer.anchor_after(end_point);
             excerpt.max_buffer_row = end_point.row;
 
             excerpt.text_summary = excerpt
                 .buffer
-                .text_summary_for_range(start_point..end_point);
+                .text_summary_for_range(excerpt.range.context.clone());
 
             let new_start_offset = new_excerpts.summary().text.len;
             let old_start_offset = cursor.start().1;
@@ -1920,7 +1983,12 @@ impl MultiBuffer {
 
                 log::info!("Expanding excerpts {excerpts:?} by {line_count} lines");
 
-                self.expand_excerpts(excerpts.iter().cloned(), line_count, cx);
+                self.expand_excerpts(
+                    excerpts.iter().cloned(),
+                    line_count,
+                    ExpandExcerptDirection::UpAndDown,
+                    cx,
+                );
                 continue;
             }
 
@@ -3018,24 +3086,37 @@ impl MultiBufferSnapshot {
             cursor.next(&());
         }
 
-        let mut prev_buffer_id = cursor.prev_item().map(|excerpt| excerpt.buffer_id);
+        let mut visited_end = false;
         std::iter::from_fn(move || {
             if self.singleton {
                 None
             } else if bounds.contains(&cursor.start().0) {
-                let excerpt = cursor.item()?;
-                let starts_new_buffer = Some(excerpt.buffer_id) != prev_buffer_id;
-                let boundary = ExcerptBoundary {
+                let next = cursor.item().map(|excerpt| ExcerptInfo {
                     id: excerpt.id,
-                    row: MultiBufferRow(cursor.start().1.row),
                     buffer: excerpt.buffer.clone(),
+                    buffer_id: excerpt.buffer_id,
                     range: excerpt.range.clone(),
-                    starts_new_buffer,
-                };
+                });
+
+                if next.is_none() {
+                    if visited_end {
+                        return None;
+                    } else {
+                        visited_end = true;
+                    }
+                }
+
+                let prev = cursor.prev_item().map(|prev_excerpt| ExcerptInfo {
+                    id: prev_excerpt.id,
+                    buffer: prev_excerpt.buffer.clone(),
+                    buffer_id: prev_excerpt.buffer_id,
+                    range: prev_excerpt.range.clone(),
+                });
+                let row = MultiBufferRow(cursor.start().1.row);
 
-                prev_buffer_id = Some(excerpt.buffer_id);
                 cursor.next(&());
-                Some(boundary)
+
+                Some(ExcerptBoundary { row, prev, next })
             } else {
                 None
             }
@@ -4537,15 +4618,16 @@ where
         .peekable();
     while let Some(range) = range_iter.next() {
         let excerpt_start = Point::new(range.start.row.saturating_sub(context_line_count), 0);
-        // These + 1s ensure that we select the whole next line
-        let mut excerpt_end = Point::new(range.end.row + 1 + context_line_count, 0).min(max_point);
+        let row = (range.end.row + context_line_count).min(max_point.row);
+        let mut excerpt_end = Point::new(row, buffer.line_len(row));
 
         let mut ranges_in_excerpt = 1;
 
         while let Some(next_range) = range_iter.peek() {
             if next_range.start.row <= excerpt_end.row + context_line_count {
-                excerpt_end =
-                    Point::new(next_range.end.row + 1 + context_line_count, 0).min(max_point);
+                let row = (next_range.end.row + context_line_count).min(max_point.row);
+                excerpt_end = Point::new(row, buffer.line_len(row));
+
                 ranges_in_excerpt += 1;
                 range_iter.next();
             } else {
@@ -4866,15 +4948,17 @@ mod tests {
         ) -> Vec<(MultiBufferRow, String, bool)> {
             snapshot
                 .excerpt_boundaries_in_range(range)
-                .map(|boundary| {
-                    (
-                        boundary.row,
-                        boundary
-                            .buffer
-                            .text_for_range(boundary.range.context)
-                            .collect::<String>(),
-                        boundary.starts_new_buffer,
-                    )
+                .filter_map(|boundary| {
+                    let starts_new_buffer = boundary.starts_new_buffer();
+                    boundary.next.map(|next| {
+                        (
+                            boundary.row,
+                            next.buffer
+                                .text_for_range(next.range.context)
+                                .collect::<String>(),
+                            starts_new_buffer,
+                        )
+                    })
                 })
                 .collect::<Vec<_>>()
         }
@@ -5006,8 +5090,33 @@ mod tests {
             )
         });
 
+        let snapshot = multibuffer.read(cx).snapshot(cx);
+
+        assert_eq!(
+            snapshot.text(),
+            concat!(
+                "ccc\n", //
+                "ddd\n", //
+                "eee",   //
+                "\n",    // End of excerpt
+                "ggg\n", //
+                "hhh\n", //
+                "iii",   //
+                "\n",    // End of excerpt
+                "ooo\n", //
+                "ppp\n", //
+                "qqq",   // End of excerpt
+            )
+        );
+        drop(snapshot);
+
         multibuffer.update(cx, |multibuffer, cx| {
-            multibuffer.expand_excerpts(multibuffer.excerpt_ids(), 1, cx)
+            multibuffer.expand_excerpts(
+                multibuffer.excerpt_ids(),
+                1,
+                ExpandExcerptDirection::UpAndDown,
+                cx,
+            )
         });
 
         let snapshot = multibuffer.read(cx).snapshot(cx);
@@ -5018,23 +5127,21 @@ mod tests {
         assert_eq!(
             snapshot.text(),
             concat!(
-                "bbb\n", // Preserve newlines
+                "bbb\n", //
                 "ccc\n", //
                 "ddd\n", //
                 "eee\n", //
-                "fff\n", // <- Same as below
-                "\n",    // Excerpt boundary
-                "fff\n", // <- Same as above
+                "fff\n", // End of excerpt
+                "fff\n", //
                 "ggg\n", //
                 "hhh\n", //
                 "iii\n", //
-                "jjj\n", //
-                "\n",    //
+                "jjj\n", // End of excerpt
                 "nnn\n", //
                 "ooo\n", //
                 "ppp\n", //
                 "qqq\n", //
-                "rrr\n", //
+                "rrr",   // End of excerpt
             )
         );
     }
@@ -5071,12 +5178,11 @@ mod tests {
                 "hhh\n", //
                 "iii\n", //
                 "jjj\n", //
-                "\n",    //
                 "nnn\n", //
                 "ooo\n", //
                 "ppp\n", //
                 "qqq\n", //
-                "rrr\n", //
+                "rrr",   //
             )
         );
 
@@ -5088,7 +5194,7 @@ mod tests {
             vec![
                 Point::new(2, 2)..Point::new(3, 2),
                 Point::new(6, 1)..Point::new(6, 3),
-                Point::new(12, 0)..Point::new(12, 0)
+                Point::new(11, 0)..Point::new(11, 0)
             ]
         );
     }
@@ -5123,12 +5229,11 @@ mod tests {
                 "hhh\n", //
                 "iii\n", //
                 "jjj\n", //
-                "\n",    //
                 "nnn\n", //
                 "ooo\n", //
                 "ppp\n", //
                 "qqq\n", //
-                "rrr\n", //
+                "rrr",   //
             )
         );
 
@@ -5140,7 +5245,7 @@ mod tests {
             vec![
                 Point::new(2, 2)..Point::new(3, 2),
                 Point::new(6, 1)..Point::new(6, 3),
-                Point::new(12, 0)..Point::new(12, 0)
+                Point::new(11, 0)..Point::new(11, 0)
             ]
         );
     }
@@ -5404,7 +5509,12 @@ mod tests {
                             .map(|id| excerpt_ids.iter().position(|i| i == id).unwrap())
                             .collect::<Vec<_>>();
                         log::info!("Expanding excerpts {excerpt_ixs:?} by {line_count} lines");
-                        multibuffer.expand_excerpts(excerpts.iter().cloned(), line_count, cx);
+                        multibuffer.expand_excerpts(
+                            excerpts.iter().cloned(),
+                            line_count,
+                            ExpandExcerptDirection::UpAndDown,
+                            cx,
+                        );
 
                         if line_count > 0 {
                             for id in excerpts {
@@ -5418,6 +5528,7 @@ mod tests {
                                     Point::new(point_range.end.row + line_count, 0),
                                     Bias::Left,
                                 );
+                                point_range.end.column = snapshot.line_len(point_range.end.row);
                                 *range = snapshot.anchor_before(point_range.start)
                                     ..snapshot.anchor_after(point_range.end);
                             }

crates/search/src/project_search.rs 🔗

@@ -653,7 +653,7 @@ impl ProjectSearchView {
             editor
         });
         let results_editor = cx.new_view(|cx| {
-            let mut editor = Editor::for_multibuffer(excerpts, Some(project.clone()), cx);
+            let mut editor = Editor::for_multibuffer(excerpts, Some(project.clone()), true, cx);
             editor.set_searchable(false);
             editor
         });
@@ -1722,7 +1722,7 @@ pub mod tests {
                 search_view
                     .results_editor
                     .update(cx, |editor, cx| editor.display_text(cx)),
-                "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;"
+                "\n\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\n\n\nconst TWO: usize = one::ONE + one::ONE;\n"
             );
             let match_background_color = cx.theme().colors().search_match_background;
             assert_eq!(
@@ -1731,15 +1731,15 @@ pub mod tests {
                     .update(cx, |editor, cx| editor.all_text_background_highlights(cx)),
                 &[
                     (
-                        DisplayPoint::new(DisplayRow(2), 32)..DisplayPoint::new(DisplayRow(2), 35),
+                        DisplayPoint::new(DisplayRow(3), 32)..DisplayPoint::new(DisplayRow(3), 35),
                         match_background_color
                     ),
                     (
-                        DisplayPoint::new(DisplayRow(2), 37)..DisplayPoint::new(DisplayRow(2), 40),
+                        DisplayPoint::new(DisplayRow(3), 37)..DisplayPoint::new(DisplayRow(3), 40),
                         match_background_color
                     ),
                     (
-                        DisplayPoint::new(DisplayRow(5), 6)..DisplayPoint::new(DisplayRow(5), 9),
+                        DisplayPoint::new(DisplayRow(8), 6)..DisplayPoint::new(DisplayRow(8), 9),
                         match_background_color
                     )
                 ]
@@ -1749,7 +1749,7 @@ pub mod tests {
                 search_view
                     .results_editor
                     .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
-                [DisplayPoint::new(DisplayRow(2), 32)..DisplayPoint::new(DisplayRow(2), 35)]
+                [DisplayPoint::new(DisplayRow(3), 32)..DisplayPoint::new(DisplayRow(3), 35)]
             );
 
             search_view.select_match(Direction::Next, cx);
@@ -1762,7 +1762,7 @@ pub mod tests {
                     search_view
                         .results_editor
                         .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
-                    [DisplayPoint::new(DisplayRow(2), 37)..DisplayPoint::new(DisplayRow(2), 40)]
+                    [DisplayPoint::new(DisplayRow(3), 37)..DisplayPoint::new(DisplayRow(3), 40)]
                 );
                 search_view.select_match(Direction::Next, cx);
             })
@@ -1775,7 +1775,7 @@ pub mod tests {
                     search_view
                         .results_editor
                         .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
-                    [DisplayPoint::new(DisplayRow(5), 6)..DisplayPoint::new(DisplayRow(5), 9)]
+                    [DisplayPoint::new(DisplayRow(8), 6)..DisplayPoint::new(DisplayRow(8), 9)]
                 );
                 search_view.select_match(Direction::Next, cx);
             })
@@ -1788,7 +1788,7 @@ pub mod tests {
                     search_view
                         .results_editor
                         .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
-                    [DisplayPoint::new(DisplayRow(2), 32)..DisplayPoint::new(DisplayRow(2), 35)]
+                    [DisplayPoint::new(DisplayRow(3), 32)..DisplayPoint::new(DisplayRow(3), 35)]
                 );
                 search_view.select_match(Direction::Prev, cx);
             })
@@ -1801,7 +1801,7 @@ pub mod tests {
                     search_view
                         .results_editor
                         .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
-                    [DisplayPoint::new(DisplayRow(5), 6)..DisplayPoint::new(DisplayRow(5), 9)]
+                    [DisplayPoint::new(DisplayRow(8), 6)..DisplayPoint::new(DisplayRow(8), 9)]
                 );
                 search_view.select_match(Direction::Prev, cx);
             })
@@ -1814,7 +1814,7 @@ pub mod tests {
                     search_view
                         .results_editor
                         .update(cx, |editor, cx| editor.selections.display_ranges(cx)),
-                    [DisplayPoint::new(DisplayRow(2), 37)..DisplayPoint::new(DisplayRow(2), 40)]
+                    [DisplayPoint::new(DisplayRow(3), 37)..DisplayPoint::new(DisplayRow(3), 40)]
                 );
             })
             .unwrap();
@@ -1982,7 +1982,7 @@ pub mod tests {
                     search_view
                         .results_editor
                         .update(cx, |editor, cx| editor.display_text(cx)),
-                    "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
+                    "\n\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\n\n\nconst TWO: usize = one::ONE + one::ONE;\n",
                     "Search view results should match the query"
                 );
                 assert!(
@@ -2021,7 +2021,7 @@ pub mod tests {
                     search_view
                         .results_editor
                         .update(cx, |editor, cx| editor.display_text(cx)),
-                    "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
+                    "\n\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\n\n\nconst TWO: usize = one::ONE + one::ONE;\n",
                     "Results should be unchanged after search view 2nd open in a row"
                 );
                 assert!(
@@ -2213,7 +2213,7 @@ pub mod tests {
                     search_view
                         .results_editor
                         .update(cx, |editor, cx| editor.display_text(cx)),
-                    "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
+                    "\n\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\n\n\nconst TWO: usize = one::ONE + one::ONE;\n",
                     "Search view results should match the query"
                 );
                 assert!(
@@ -2268,7 +2268,7 @@ pub mod tests {
                         search_view
                             .results_editor
                             .update(cx, |editor, cx| editor.display_text(cx)),
-                        "\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
+                        "\n\n\nconst THREE: usize = one::ONE + two::TWO;\n\n\n\n\nconst TWO: usize = one::ONE + one::ONE;\n",
                         "Results of the first search view should not update too"
                     );
                     assert!(
@@ -2317,7 +2317,7 @@ pub mod tests {
                         search_view_2
                             .results_editor
                             .update(cx, |editor, cx| editor.display_text(cx)),
-                        "\n\nconst FOUR: usize = one::ONE + three::THREE;",
+                        "\n\n\nconst FOUR: usize = one::ONE + three::THREE;\n",
                         "New search view with the updated query should have new search results"
                     );
                     assert!(
@@ -2462,7 +2462,7 @@ pub mod tests {
                 search_view
                     .results_editor
                     .update(cx, |editor, cx| editor.display_text(cx)),
-                "\n\nconst ONE: usize = 1;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
+                "\n\n\nconst ONE: usize = 1;\n\n\n\n\nconst TWO: usize = one::ONE + one::ONE;\n",
                 "New search in directory should have a filter that matches a certain directory"
             );
                 })

crates/ui/src/components/icon.rs 🔗

@@ -77,9 +77,11 @@ pub enum IconName {
     Ai,
     ArrowCircle,
     ArrowDown,
+    ArrowDownFromLine,
     ArrowLeft,
     ArrowRight,
     ArrowUp,
+    ArrowUpFromLine,
     ArrowUpRight,
     AtSign,
     AudioOff,
@@ -193,6 +195,7 @@ impl IconName {
             IconName::Ai => "icons/ai.svg",
             IconName::ArrowCircle => "icons/arrow_circle.svg",
             IconName::ArrowDown => "icons/arrow_down.svg",
+            IconName::ArrowDownFromLine => "icons/arrow_down_from_line.svg",
             IconName::ArrowLeft => "icons/arrow_left.svg",
             IconName::ArrowRight => "icons/arrow_right.svg",
             IconName::ArrowUp => "icons/arrow_up.svg",
@@ -301,6 +304,7 @@ impl IconName {
             IconName::XCircle => "icons/error.svg",
             IconName::ZedAssistant => "icons/zed_assistant.svg",
             IconName::ZedXCopilot => "icons/zed_x_copilot.svg",
+            IconName::ArrowUpFromLine => "icons/arrow_up_from_line.svg",
         }
     }
 }

crates/zed/src/zed.rs 🔗

@@ -601,8 +601,9 @@ fn open_log_file(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) {
                         let buffer = cx.new_model(|cx| {
                             MultiBuffer::singleton(buffer, cx).with_title("Log".into())
                         });
-                        let editor =
-                            cx.new_view(|cx| Editor::for_multibuffer(buffer, Some(project), cx));
+                        let editor = cx.new_view(|cx| {
+                            Editor::for_multibuffer(buffer, Some(project), true, cx)
+                        });
 
                         editor.update(cx, |editor, cx| {
                             let last_multi_buffer_offset = editor.buffer().read(cx).len(cx);
@@ -831,7 +832,7 @@ fn open_telemetry_log_file(workspace: &mut Workspace, cx: &mut ViewContext<Works
                     MultiBuffer::singleton(buffer, cx).with_title("Telemetry Log".into())
                 });
                 workspace.add_item_to_active_pane(
-                    Box::new(cx.new_view(|cx| Editor::for_multibuffer(buffer, Some(project), cx))),
+                    Box::new(cx.new_view(|cx| Editor::for_multibuffer(buffer, Some(project), true, cx))),
                     None,cx,
                 );
             }).log_err()?;
@@ -864,7 +865,7 @@ fn open_bundled_file(
                     });
                     workspace.add_item_to_active_pane(
                         Box::new(cx.new_view(|cx| {
-                            Editor::for_multibuffer(buffer, Some(project.clone()), cx)
+                            Editor::for_multibuffer(buffer, Some(project.clone()), true, cx)
                         })),
                         None,
                         cx,