Sticky multibuffer headers (#22391)

Agus Zubiaga and Michael created

https://github.com/user-attachments/assets/92cc5ff7-d8be-4e4b-ac6e-68eb310fffce

Release Notes:

- Multibuffer headers will now stick to the top of the viewport as you
scroll
- Added support for expanding diagnostic excerpts

---------

Co-authored-by: Michael <michael@zed.dev>

Change summary

crates/diagnostics/src/diagnostics.rs       |   2 
crates/diagnostics/src/diagnostics_tests.rs |  95 ++++++++----
crates/editor/src/display_map.rs            |   5 
crates/editor/src/display_map/block_map.rs  |  66 ++++++++
crates/editor/src/element.rs                | 169 ++++++++++++++++++----
5 files changed, 268 insertions(+), 69 deletions(-)

Detailed changes

crates/diagnostics/src/diagnostics.rs 🔗

@@ -166,7 +166,7 @@ impl ProjectDiagnosticsEditor {
         let excerpts = cx.new_model(|cx| MultiBuffer::new(project_handle.read(cx).capability()));
         let editor = cx.new_view(|cx| {
             let mut editor =
-                Editor::for_multibuffer(excerpts.clone(), Some(project_handle.clone()), false, cx);
+                Editor::for_multibuffer(excerpts.clone(), Some(project_handle.clone()), true, cx);
             editor.set_vertical_scroll_margin(5, cx);
             editor
         });

crates/diagnostics/src/diagnostics_tests.rs 🔗

@@ -167,10 +167,10 @@ async fn test_diagnostics(cx: &mut TestAppContext) {
         editor_blocks(&editor, cx),
         [
             (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()),
+            (DisplayRow(3), DIAGNOSTIC_HEADER.into()),
+            (DisplayRow(16), EXCERPT_HEADER.into()),
+            (DisplayRow(18), DIAGNOSTIC_HEADER.into()),
+            (DisplayRow(27), EXCERPT_HEADER.into()),
         ]
     );
     assert_eq!(
@@ -184,6 +184,7 @@ async fn test_diagnostics(cx: &mut TestAppContext) {
             // diagnostic group 1
             "\n", // primary message
             "\n", // padding
+            "\n", // expand
             "    let x = vec![];\n",
             "    let y = vec![];\n",
             "\n", // supporting diagnostic
@@ -195,6 +196,7 @@ async fn test_diagnostics(cx: &mut TestAppContext) {
             "    c(y);\n",
             "\n", // supporting diagnostic
             "    d(x);\n",
+            "\n", // expand
             "\n", // context ellipsis
             // diagnostic group 2
             "\n", // primary message
@@ -206,11 +208,13 @@ async fn test_diagnostics(cx: &mut TestAppContext) {
             "    a(x);\n",
             "\n", // supporting diagnostic
             "    b(y);\n",
+            "\n", // expand
             "\n", // context ellipsis
             "    c(y);\n",
             "    d(x);\n",
             "\n", // supporting diagnostic
-            "}"
+            "}",
+            "\n", // expand
         )
     );
 
@@ -218,7 +222,7 @@ async fn test_diagnostics(cx: &mut TestAppContext) {
     editor.update(cx, |editor, cx| {
         assert_eq!(
             editor.selections.display_ranges(cx),
-            [DisplayPoint::new(DisplayRow(12), 6)..DisplayPoint::new(DisplayRow(12), 6)]
+            [DisplayPoint::new(DisplayRow(13), 6)..DisplayPoint::new(DisplayRow(13), 6)]
         );
     });
 
@@ -253,12 +257,12 @@ async fn test_diagnostics(cx: &mut TestAppContext) {
         editor_blocks(&editor, cx),
         [
             (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()),
+            (DisplayRow(3), DIAGNOSTIC_HEADER.into()),
+            (DisplayRow(8), FILE_HEADER.into()),
+            (DisplayRow(12), DIAGNOSTIC_HEADER.into()),
+            (DisplayRow(25), EXCERPT_HEADER.into()),
+            (DisplayRow(27), DIAGNOSTIC_HEADER.into()),
+            (DisplayRow(36), EXCERPT_HEADER.into()),
         ]
     );
 
@@ -273,6 +277,7 @@ async fn test_diagnostics(cx: &mut TestAppContext) {
             // diagnostic group 1
             "\n", // primary message
             "\n", // padding
+            "\n", // expand
             "const a: i32 = 'a';\n",
             "\n", // supporting diagnostic
             "const b: i32 = c;\n",
@@ -284,6 +289,8 @@ async fn test_diagnostics(cx: &mut TestAppContext) {
             // diagnostic group 1
             "\n", // primary message
             "\n", // padding
+            "\n", // expand
+            "\n", // expand
             "    let x = vec![];\n",
             "    let y = vec![];\n",
             "\n", // supporting diagnostic
@@ -299,6 +306,7 @@ async fn test_diagnostics(cx: &mut TestAppContext) {
             // diagnostic group 2
             "\n", // primary message
             "\n", // filename
+            "\n", // expand
             "fn main() {\n",
             "    let x = vec![];\n",
             "\n", // supporting diagnostic
@@ -306,11 +314,13 @@ async fn test_diagnostics(cx: &mut TestAppContext) {
             "    a(x);\n",
             "\n", // supporting diagnostic
             "    b(y);\n",
+            "\n", // expand
             "\n", // context ellipsis
             "    c(y);\n",
             "    d(x);\n",
             "\n", // supporting diagnostic
-            "}"
+            "}",
+            "\n", // expand
         )
     );
 
@@ -318,7 +328,7 @@ async fn test_diagnostics(cx: &mut TestAppContext) {
     editor.update(cx, |editor, cx| {
         assert_eq!(
             editor.selections.display_ranges(cx),
-            [DisplayPoint::new(DisplayRow(19), 6)..DisplayPoint::new(DisplayRow(19), 6)]
+            [DisplayPoint::new(DisplayRow(22), 6)..DisplayPoint::new(DisplayRow(22), 6)]
         );
     });
 
@@ -366,14 +376,14 @@ async fn test_diagnostics(cx: &mut TestAppContext) {
         editor_blocks(&editor, cx),
         [
             (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()),
+            (DisplayRow(3), DIAGNOSTIC_HEADER.into()),
+            (DisplayRow(8), EXCERPT_HEADER.into()),
+            (DisplayRow(10), DIAGNOSTIC_HEADER.into()),
+            (DisplayRow(15), FILE_HEADER.into()),
+            (DisplayRow(19), DIAGNOSTIC_HEADER.into()),
+            (DisplayRow(32), EXCERPT_HEADER.into()),
+            (DisplayRow(34), DIAGNOSTIC_HEADER.into()),
+            (DisplayRow(43), EXCERPT_HEADER.into()),
         ]
     );
 
@@ -388,6 +398,7 @@ async fn test_diagnostics(cx: &mut TestAppContext) {
             // diagnostic group 1
             "\n", // primary message
             "\n", // padding
+            "\n", // expand
             "const a: i32 = 'a';\n",
             "\n", // supporting diagnostic
             "const b: i32 = c;\n",
@@ -395,6 +406,7 @@ async fn test_diagnostics(cx: &mut TestAppContext) {
             // diagnostic group 2
             "\n", // primary message
             "\n", // padding
+            "\n", // expand
             "const a: i32 = 'a';\n",
             "const b: i32 = c;\n",
             "\n", // supporting diagnostic
@@ -406,6 +418,8 @@ async fn test_diagnostics(cx: &mut TestAppContext) {
             // diagnostic group 1
             "\n", // primary message
             "\n", // padding
+            "\n", // expand
+            "\n", // expand
             "    let x = vec![];\n",
             "    let y = vec![];\n",
             "\n", // supporting diagnostic
@@ -421,6 +435,7 @@ async fn test_diagnostics(cx: &mut TestAppContext) {
             // diagnostic group 2
             "\n", // primary message
             "\n", // filename
+            "\n", // expand
             "fn main() {\n",
             "    let x = vec![];\n",
             "\n", // supporting diagnostic
@@ -428,11 +443,13 @@ async fn test_diagnostics(cx: &mut TestAppContext) {
             "    a(x);\n",
             "\n", // supporting diagnostic
             "    b(y);\n",
+            "\n", // expand
             "\n", // context ellipsis
             "    c(y);\n",
             "    d(x);\n",
             "\n", // supporting diagnostic
-            "}"
+            "}",
+            "\n", // expand
         )
     );
 }
@@ -513,7 +530,7 @@ async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) {
         editor_blocks(&editor, cx),
         [
             (DisplayRow(0), FILE_HEADER.into()),
-            (DisplayRow(2), DIAGNOSTIC_HEADER.into()),
+            (DisplayRow(3), DIAGNOSTIC_HEADER.into()),
         ]
     );
     assert_eq!(
@@ -524,8 +541,9 @@ async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) {
             // diagnostic group 1
             "\n",     // primary message
             "\n",     // padding
+            "\n",     // expand
             "a();\n", //
-            "b();",
+            "b();", "\n", // expand
         )
     );
 
@@ -561,9 +579,9 @@ async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) {
         editor_blocks(&editor, cx),
         [
             (DisplayRow(0), FILE_HEADER.into()),
-            (DisplayRow(2), DIAGNOSTIC_HEADER.into()),
-            (DisplayRow(6), EXCERPT_HEADER.into()),
-            (DisplayRow(7), DIAGNOSTIC_HEADER.into()),
+            (DisplayRow(3), DIAGNOSTIC_HEADER.into()),
+            (DisplayRow(7), EXCERPT_HEADER.into()),
+            (DisplayRow(9), DIAGNOSTIC_HEADER.into()),
         ]
     );
     assert_eq!(
@@ -574,8 +592,10 @@ async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) {
             // diagnostic group 1
             "\n",     // primary message
             "\n",     // padding
+            "\n",     // expand
             "a();\n", // location
             "b();\n", //
+            "\n",     // expand
             "\n",     // collapsed context
             // diagnostic group 2
             "\n",     // primary message
@@ -583,6 +603,7 @@ async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) {
             "a();\n", // context
             "b();\n", //
             "c();",   // context
+            "\n",     // expand
         )
     );
 
@@ -629,9 +650,9 @@ async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) {
         editor_blocks(&editor, cx),
         [
             (DisplayRow(0), FILE_HEADER.into()),
-            (DisplayRow(2), DIAGNOSTIC_HEADER.into()),
-            (DisplayRow(7), EXCERPT_HEADER.into()),
-            (DisplayRow(8), DIAGNOSTIC_HEADER.into()),
+            (DisplayRow(3), DIAGNOSTIC_HEADER.into()),
+            (DisplayRow(8), EXCERPT_HEADER.into()),
+            (DisplayRow(10), DIAGNOSTIC_HEADER.into()),
         ]
     );
     assert_eq!(
@@ -642,9 +663,11 @@ async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) {
             // diagnostic group 1
             "\n",     // primary message
             "\n",     // padding
+            "\n",     // expand
             "a();\n", // location
             "b();\n", //
             "c();\n", // context
+            "\n",     // expand
             "\n",     // collapsed context
             // diagnostic group 2
             "\n",     // primary message
@@ -652,6 +675,7 @@ async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) {
             "b();\n", // context
             "c();\n", //
             "d();",   // context
+            "\n",     // expand
         )
     );
 
@@ -687,9 +711,9 @@ async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) {
         editor_blocks(&editor, cx),
         [
             (DisplayRow(0), FILE_HEADER.into()),
-            (DisplayRow(2), DIAGNOSTIC_HEADER.into()),
-            (DisplayRow(7), EXCERPT_HEADER.into()),
-            (DisplayRow(8), DIAGNOSTIC_HEADER.into()),
+            (DisplayRow(3), DIAGNOSTIC_HEADER.into()),
+            (DisplayRow(8), EXCERPT_HEADER.into()),
+            (DisplayRow(10), DIAGNOSTIC_HEADER.into()),
         ]
     );
     assert_eq!(
@@ -700,9 +724,11 @@ async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) {
             // diagnostic group 1
             "\n",     // primary message
             "\n",     // padding
+            "\n",     // expand
             "b();\n", // location
             "c();\n", //
             "d();\n", // context
+            "\n",     // expand
             "\n",     // collapsed context
             // diagnostic group 2
             "\n",     // primary message
@@ -710,6 +736,7 @@ async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) {
             "c();\n", // context
             "d();\n", //
             "e();",   // context
+            "\n",     // expand
         )
     );
 }

crates/editor/src/display_map.rs 🔗

@@ -32,6 +32,7 @@ use crate::{
 pub use block_map::{
     Block, BlockBufferRows, BlockChunks as DisplayChunks, BlockContext, BlockId, BlockMap,
     BlockPlacement, BlockPoint, BlockProperties, BlockStyle, CustomBlockId, RenderBlock,
+    StickyHeaderExcerpt,
 };
 use block_map::{BlockRow, BlockSnapshot};
 use collections::{HashMap, HashSet};
@@ -1105,6 +1106,10 @@ impl DisplaySnapshot {
             .map(|(row, block)| (DisplayRow(row), block))
     }
 
+    pub fn sticky_header_excerpt(&self, row: DisplayRow) -> Option<StickyHeaderExcerpt<'_>> {
+        self.block_snapshot.sticky_header_excerpt(row.0)
+    }
+
     pub fn block_for_id(&self, id: BlockId) -> Option<Block> {
         self.block_snapshot.block_for_id(id)
     }

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

@@ -1411,6 +1411,66 @@ impl BlockSnapshot {
         })
     }
 
+    pub fn sticky_header_excerpt(&self, top_row: u32) -> Option<StickyHeaderExcerpt<'_>> {
+        let mut cursor = self.transforms.cursor::<BlockRow>(&());
+        cursor.seek(&BlockRow(top_row), Bias::Left, &());
+
+        while let Some(transform) = cursor.item() {
+            let start = cursor.start().0;
+            let end = cursor.end(&()).0;
+
+            match &transform.block {
+                Some(Block::ExcerptBoundary {
+                    prev_excerpt,
+                    next_excerpt,
+                    starts_new_buffer,
+                    show_excerpt_controls,
+                    ..
+                }) => {
+                    let matches_start = if *show_excerpt_controls && prev_excerpt.is_some() {
+                        start < top_row
+                    } else {
+                        start <= top_row
+                    };
+
+                    if matches_start && top_row <= end {
+                        return next_excerpt.as_ref().map(|excerpt| StickyHeaderExcerpt {
+                            next_buffer_row: None,
+                            next_excerpt_controls_present: *show_excerpt_controls,
+                            excerpt,
+                        });
+                    }
+
+                    let next_buffer_row = if *starts_new_buffer { Some(end) } else { None };
+
+                    return prev_excerpt.as_ref().map(|excerpt| StickyHeaderExcerpt {
+                        excerpt,
+                        next_buffer_row,
+                        next_excerpt_controls_present: *show_excerpt_controls,
+                    });
+                }
+                Some(Block::FoldedBuffer {
+                    prev_excerpt: Some(excerpt),
+                    ..
+                }) if top_row <= start => {
+                    return Some(StickyHeaderExcerpt {
+                        next_buffer_row: Some(end),
+                        next_excerpt_controls_present: false,
+                        excerpt,
+                    });
+                }
+                Some(Block::FoldedBuffer { .. }) | Some(Block::Custom(_)) | None => {}
+            }
+
+            // This is needed to iterate past None / FoldedBuffer / Custom blocks. For FoldedBuffer,
+            // if scrolled slightly past the header of a folded block, the next block is needed for
+            // the sticky header.
+            cursor.next(&());
+        }
+
+        None
+    }
+
     pub fn block_for_id(&self, block_id: BlockId) -> Option<Block> {
         let buffer = self.wrap_snapshot.buffer_snapshot();
         let wrap_point = match block_id {
@@ -1694,6 +1754,12 @@ impl<'a> BlockChunks<'a> {
     }
 }
 
+pub struct StickyHeaderExcerpt<'a> {
+    pub excerpt: &'a ExcerptInfo,
+    pub next_excerpt_controls_present: bool,
+    pub next_buffer_row: Option<u32>,
+}
+
 impl<'a> Iterator for BlockChunks<'a> {
     type Item = Chunk<'a>;
 

crates/editor/src/element.rs 🔗

@@ -22,7 +22,7 @@ use crate::{
     EditorSnapshot, EditorStyle, ExpandExcerpts, FocusedBlock, GutterDimensions, HalfPageDown,
     HalfPageUp, HandleInput, HoveredCursor, HoveredHunk, InlineCompletion, JumpData, LineDown,
     LineUp, OpenExcerpts, PageDown, PageUp, Point, RowExt, RowRangeExt, SelectPhase, Selection,
-    SoftWrap, ToPoint, ToggleFold, CURSORS_VISIBLE_FOR, FILE_HEADER_HEIGHT,
+    SoftWrap, StickyHeaderExcerpt, ToPoint, ToggleFold, CURSORS_VISIBLE_FOR, FILE_HEADER_HEIGHT,
     GIT_BLAME_MAX_AUTHOR_CHARS_DISPLAYED, MAX_LINE_LEN, MULTI_BUFFER_EXCERPT_HEADER_HEIGHT,
 };
 use client::ParticipantIndex;
@@ -30,14 +30,14 @@ use collections::{BTreeMap, HashMap, HashSet};
 use file_icons::FileIcons;
 use git::{blame::BlameEntry, diff::DiffHunkStatus, Oid};
 use gpui::{
-    anchored, deferred, div, fill, outline, point, px, quad, relative, size, svg,
-    transparent_black, Action, AnyElement, AvailableSpace, Axis, Bounds, ClickEvent, ClipboardItem,
-    ContentMask, Corner, Corners, CursorStyle, DispatchPhase, Edges, Element, ElementInputHandler,
-    Entity, FontId, GlobalElementId, Hitbox, Hsla, InteractiveElement, IntoElement, Length,
-    ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad,
-    ParentElement, Pixels, ScrollDelta, ScrollWheelEvent, ShapedLine, SharedString, Size,
-    StatefulInteractiveElement, Style, Styled, Subscription, TextRun, TextStyleRefinement, View,
-    ViewContext, WeakView, WindowContext,
+    anchored, deferred, div, fill, linear_color_stop, linear_gradient, outline, point, px, quad,
+    relative, size, svg, transparent_black, Action, AnyElement, AvailableSpace, Axis, Bounds,
+    ClickEvent, ClipboardItem, ContentMask, Corner, Corners, CursorStyle, DispatchPhase, Edges,
+    Element, ElementInputHandler, Entity, FontId, GlobalElementId, Hitbox, Hsla,
+    InteractiveElement, IntoElement, Length, ModifiersChangedEvent, MouseButton, MouseDownEvent,
+    MouseMoveEvent, MouseUpEvent, PaintQuad, ParentElement, Pixels, ScrollDelta, ScrollWheelEvent,
+    ShapedLine, SharedString, Size, StatefulInteractiveElement, Style, Styled, Subscription,
+    TextRun, TextStyleRefinement, View, ViewContext, WeakView, WindowContext,
 };
 use itertools::Itertools;
 use language::{
@@ -2210,9 +2210,9 @@ impl EditorElement {
         resized_blocks: &mut HashMap<CustomBlockId, u32>,
         selections: &[Selection<Point>],
         is_row_soft_wrapped: impl Copy + Fn(usize) -> bool,
+        sticky_header_excerpt_id: Option<ExcerptId>,
         cx: &mut WindowContext,
     ) -> (AnyElement, Size<Pixels>) {
-        let header_padding = px(6.0);
         let mut element = match block {
             Block::Custom(block) => {
                 let block_start = block.start().to_point(&snapshot.buffer_snapshot);
@@ -2305,14 +2305,7 @@ impl EditorElement {
 
                 let jump_data = jump_data(snapshot, block_row_start, *height, first_excerpt, cx);
                 result
-                    .child(self.render_buffer_header(
-                        first_excerpt,
-                        header_padding,
-                        true,
-                        selected,
-                        jump_data,
-                        cx,
-                    ))
+                    .child(self.render_buffer_header(first_excerpt, true, selected, jump_data, cx))
                     .into_any_element()
             }
             Block::ExcerptBoundary {
@@ -2347,14 +2340,19 @@ impl EditorElement {
                 if let Some(next_excerpt) = next_excerpt {
                     let jump_data = jump_data(snapshot, block_row_start, *height, next_excerpt, cx);
                     if *starts_new_buffer {
-                        result = result.child(self.render_buffer_header(
-                            next_excerpt,
-                            header_padding,
-                            false,
-                            false,
-                            jump_data,
-                            cx,
-                        ));
+                        if sticky_header_excerpt_id != Some(next_excerpt.id) {
+                            result = result.child(self.render_buffer_header(
+                                next_excerpt,
+                                false,
+                                false,
+                                jump_data,
+                                cx,
+                            ));
+                        } else {
+                            result =
+                                result.child(div().h(FILE_HEADER_HEIGHT as f32 * cx.line_height()));
+                        }
+
                         if *show_excerpt_controls {
                             result = result.child(
                                 h_flex()
@@ -2507,7 +2505,6 @@ impl EditorElement {
     fn render_buffer_header(
         &self,
         for_excerpt: &ExcerptInfo,
-        header_padding: Pixels,
         is_folded: bool,
         is_selected: bool,
         jump_data: JumpData,
@@ -2531,8 +2528,8 @@ impl EditorElement {
         let focus_handle = self.editor.focus_handle(cx);
 
         div()
-            .px(header_padding)
-            .pt(header_padding)
+            .px_2()
+            .pt_2()
             .w_full()
             .h(FILE_HEADER_HEIGHT as f32 * cx.line_height())
             .child(
@@ -2686,6 +2683,7 @@ impl EditorElement {
         line_layouts: &[LineWithInvisibles],
         selections: &[Selection<Point>],
         is_row_soft_wrapped: impl Copy + Fn(usize) -> bool,
+        sticky_header_excerpt_id: Option<ExcerptId>,
         cx: &mut WindowContext,
     ) -> Result<Vec<BlockLayout>, HashMap<CustomBlockId, u32>> {
         let (fixed_blocks, non_fixed_blocks) = snapshot
@@ -2724,6 +2722,7 @@ impl EditorElement {
                 &mut resized_blocks,
                 selections,
                 is_row_soft_wrapped,
+                sticky_header_excerpt_id,
                 cx,
             );
             fixed_block_max_width = fixed_block_max_width.max(element_size.width + em_width);
@@ -2735,6 +2734,7 @@ impl EditorElement {
                 style: BlockStyle::Fixed,
             });
         }
+
         for (row, block) in non_fixed_blocks {
             let style = block.style();
             let width = match style {
@@ -2770,6 +2770,7 @@ impl EditorElement {
                 &mut resized_blocks,
                 selections,
                 is_row_soft_wrapped,
+                sticky_header_excerpt_id,
                 cx,
             );
 
@@ -2817,6 +2818,7 @@ impl EditorElement {
                             &mut resized_blocks,
                             selections,
                             is_row_soft_wrapped,
+                            sticky_header_excerpt_id,
                             cx,
                         );
 
@@ -2883,6 +2885,71 @@ impl EditorElement {
         }
     }
 
+    fn layout_sticky_buffer_header(
+        &self,
+        StickyHeaderExcerpt {
+            excerpt,
+            next_excerpt_controls_present,
+            next_buffer_row,
+        }: StickyHeaderExcerpt<'_>,
+        scroll_position: f32,
+        line_height: Pixels,
+        snapshot: &EditorSnapshot,
+        hitbox: &Hitbox,
+        cx: &mut WindowContext,
+    ) -> AnyElement {
+        let jump_data = jump_data(snapshot, DisplayRow(0), FILE_HEADER_HEIGHT, excerpt, cx);
+
+        let editor_bg_color = cx.theme().colors().editor_background;
+
+        let mut header = v_flex()
+            .relative()
+            .child(
+                div()
+                    .w(hitbox.bounds.size.width)
+                    .h(FILE_HEADER_HEIGHT as f32 * line_height)
+                    .bg(linear_gradient(
+                        0.,
+                        linear_color_stop(editor_bg_color.opacity(0.), 0.),
+                        linear_color_stop(editor_bg_color, 0.6),
+                    ))
+                    .absolute()
+                    .top_0(),
+            )
+            .child(
+                self.render_buffer_header(excerpt, false, false, jump_data, cx)
+                    .into_any_element(),
+            )
+            .into_any_element();
+
+        let mut origin = hitbox.origin;
+
+        if let Some(next_buffer_row) = next_buffer_row {
+            // Push up the sticky header when the excerpt is getting close to the top of the viewport
+
+            let mut max_row = next_buffer_row - FILE_HEADER_HEIGHT * 2;
+
+            if next_excerpt_controls_present {
+                max_row -= MULTI_BUFFER_EXCERPT_HEADER_HEIGHT;
+            }
+
+            let offset = scroll_position - max_row as f32;
+
+            if offset > 0.0 {
+                origin.y -= Pixels(offset) * line_height;
+            }
+        }
+
+        let size = size(
+            AvailableSpace::Definite(hitbox.size.width),
+            AvailableSpace::MinContent,
+        );
+
+        header.prepaint_as_root(origin, size, cx);
+
+        header
+    }
+
     #[allow(clippy::too_many_arguments)]
     fn layout_context_menu(
         &self,
@@ -4945,11 +5012,14 @@ fn jump_data(
         let excerpt_start_row = language::ToPoint::to_point(&jump_anchor, buffer).row;
         jump_position.row - excerpt_start_row
     };
-    let line_offset_from_top = block_row_start.0 + height + offset_from_excerpt_start
-        - snapshot
-            .scroll_anchor
-            .scroll_position(&snapshot.display_snapshot)
-            .y as u32;
+    let line_offset_from_top = block_row_start.0
+        + height
+        + offset_from_excerpt_start.saturating_sub(
+            snapshot
+                .scroll_anchor
+                .scroll_position(&snapshot.display_snapshot)
+                .y as u32,
+        );
     JumpData {
         excerpt_id: for_excerpt.id,
         anchor: jump_anchor,
@@ -6096,6 +6166,14 @@ impl Element for EditorElement {
                     let scroll_range_bounds = scrollbar_range_data.scroll_range;
                     let mut scroll_width = scroll_range_bounds.size.width;
 
+                    let sticky_header_excerpt = if snapshot.buffer_snapshot.show_headers() {
+                        snapshot.sticky_header_excerpt(start_row)
+                    } else {
+                        None
+                    };
+                    let sticky_header_excerpt_id =
+                        sticky_header_excerpt.as_ref().map(|top| top.excerpt.id);
+
                     let blocks = cx.with_element_namespace("blocks", |cx| {
                         self.render_blocks(
                             start_row..end_row,
@@ -6111,6 +6189,7 @@ impl Element for EditorElement {
                             &line_layouts,
                             &local_selections,
                             is_row_soft_wrapped,
+                            sticky_header_excerpt_id,
                             cx,
                         )
                     });
@@ -6124,6 +6203,19 @@ impl Element for EditorElement {
                         }
                     };
 
+                    let sticky_buffer_header = sticky_header_excerpt.map(|sticky_header_excerpt| {
+                        cx.with_element_namespace("blocks", |cx| {
+                            self.layout_sticky_buffer_header(
+                                sticky_header_excerpt,
+                                scroll_position.y,
+                                line_height,
+                                &snapshot,
+                                &hitbox,
+                                cx,
+                            )
+                        })
+                    });
+
                     let start_buffer_row =
                         MultiBufferRow(start_anchor.to_point(&snapshot.buffer_snapshot).row);
                     let end_buffer_row =
@@ -6251,6 +6343,7 @@ impl Element for EditorElement {
                     );
 
                     let mut block_start_rows = HashSet::default();
+
                     cx.with_element_namespace("blocks", |cx| {
                         self.layout_blocks(
                             &mut blocks,
@@ -6542,6 +6635,7 @@ impl Element for EditorElement {
                         crease_trailers,
                         tab_invisible,
                         space_invisible,
+                        sticky_buffer_header,
                     }
                 })
             })
@@ -6623,6 +6717,12 @@ impl Element for EditorElement {
                         });
                     }
 
+                    cx.with_element_namespace("blocks", |cx| {
+                        if let Some(mut sticky_header) = layout.sticky_buffer_header.take() {
+                            sticky_header.paint(cx)
+                        }
+                    });
+
                     self.paint_scrollbars(layout, cx);
                     self.paint_inline_completion_popover(layout, cx);
                     self.paint_mouse_context_menu(layout, cx);
@@ -6730,6 +6830,7 @@ pub struct EditorLayout {
     mouse_context_menu: Option<AnyElement>,
     tab_invisible: ShapedLine,
     space_invisible: ShapedLine,
+    sticky_buffer_header: Option<AnyElement>,
 }
 
 impl EditorLayout {