Improve multibuffer excerpt affordances (#22167)

Danilo Leal , Thorsten Ball , Agus Zubiaga , Kirill Bulatov , and Agus Zubiaga created

Changes:
- [x] Increase expand affordance surface area
- [x] Ensure expand buttons have tooltips with keybindings
- [x] Make line numbers clickable to jump you to location (only in
multibuffers)
- [x] Hide the "Jump To File" element in not-focused excerpts

Before merging it:

- [x] Fix off-by-one header focus styles glitch

Improvements to consider for follow-up PRs:

1. Experiment with increasing the width of the clickable surface area
for line numbers
2. Don't show (or disable) the "expand excerpt" button when at the top
or bottom edge of the file
3. Once you jump to location, centralize the cursor scroll position

Release Notes:

- Improved multibuffer's "expand excerpt" affordance
- Fixed "jump to file/location" and "expand excerpt" keybinding display
- Made clicking on line numbers in multibuffers jump you to cursor
location in file

---------

Co-authored-by: Thorsten Ball <mrnugget@gmail.com>
Co-authored-by: Agus Zubiaga <hi@aguz.me>
Co-authored-by: Kirill Bulatov <kirill@zed.dev>
Co-authored-by: Agus Zubiaga <agus@zed.dev>

Change summary

crates/diagnostics/src/diagnostics.rs      | 111 ++-
crates/editor/src/display_map/block_map.rs |   1 
crates/editor/src/editor.rs                |  44 +
crates/editor/src/element.rs               | 610 ++++++++++++++---------
crates/multi_buffer/src/multi_buffer.rs    |  18 
crates/ui/src/components/tooltip.rs        |  17 
6 files changed, 492 insertions(+), 309 deletions(-)

Detailed changes

crates/diagnostics/src/diagnostics.rs 🔗

@@ -836,65 +836,76 @@ fn diagnostic_header_renderer(diagnostic: Diagnostic) -> RenderBlock {
     let (message, code_ranges) = highlight_diagnostic_message(&diagnostic, None);
     let message: SharedString = message;
     Arc::new(move |cx| {
-        let highlight_style: HighlightStyle = cx.theme().colors().text_accent.into();
+        let color = cx.theme().colors();
+        let highlight_style: HighlightStyle = color.text_accent.into();
+
         h_flex()
             .id(DIAGNOSTIC_HEADER)
-            .block_mouse_down()
-            .h(2. * cx.line_height())
-            .pl_10()
-            .pr_5()
             .w_full()
-            .justify_between()
-            .gap_2()
+            .relative()
+            .child(
+                div()
+                    .top(px(0.))
+                    .absolute()
+                    .w_full()
+                    .h_px()
+                    .bg(color.border_variant),
+            )
             .child(
                 h_flex()
-                    .gap_3()
-                    .map(|stack| {
-                        stack.child(
-                            svg()
-                                .size(cx.text_style().font_size)
-                                .flex_none()
-                                .map(|icon| {
-                                    if diagnostic.severity == DiagnosticSeverity::ERROR {
-                                        icon.path(IconName::XCircle.path())
-                                            .text_color(Color::Error.color(cx))
-                                    } else {
-                                        icon.path(IconName::Warning.path())
-                                            .text_color(Color::Warning.color(cx))
-                                    }
-                                }),
-                        )
-                    })
+                    .block_mouse_down()
+                    .h(2. * cx.line_height())
+                    .pl_10()
+                    .pr_5()
+                    .w_full()
+                    .justify_between()
+                    .gap_2()
                     .child(
                         h_flex()
-                            .gap_1()
+                            .gap_3()
+                            .map(|stack| {
+                                stack.child(svg().size(cx.text_style().font_size).flex_none().map(
+                                    |icon| {
+                                        if diagnostic.severity == DiagnosticSeverity::ERROR {
+                                            icon.path(IconName::XCircle.path())
+                                                .text_color(Color::Error.color(cx))
+                                        } else {
+                                            icon.path(IconName::Warning.path())
+                                                .text_color(Color::Warning.color(cx))
+                                        }
+                                    },
+                                ))
+                            })
                             .child(
-                                StyledText::new(message.clone()).with_highlights(
-                                    &cx.text_style(),
-                                    code_ranges
-                                        .iter()
-                                        .map(|range| (range.clone(), highlight_style)),
-                                ),
+                                h_flex()
+                                    .gap_1()
+                                    .child(
+                                        StyledText::new(message.clone()).with_highlights(
+                                            &cx.text_style(),
+                                            code_ranges
+                                                .iter()
+                                                .map(|range| (range.clone(), highlight_style)),
+                                        ),
+                                    )
+                                    .when_some(diagnostic.code.as_ref(), |stack, code| {
+                                        stack.child(
+                                            div()
+                                                .child(SharedString::from(format!("({code})")))
+                                                .text_color(cx.theme().colors().text_muted),
+                                        )
+                                    }),
+                            ),
+                    )
+                    .child(h_flex().gap_1().when_some(
+                        diagnostic.source.as_ref(),
+                        |stack, source| {
+                            stack.child(
+                                div()
+                                    .child(SharedString::from(source.clone()))
+                                    .text_color(cx.theme().colors().text_muted),
                             )
-                            .when_some(diagnostic.code.as_ref(), |stack, code| {
-                                stack.child(
-                                    div()
-                                        .child(SharedString::from(format!("({code})")))
-                                        .text_color(cx.theme().colors().text_muted),
-                                )
-                            }),
-                    ),
-            )
-            .child(
-                h_flex()
-                    .gap_1()
-                    .when_some(diagnostic.source.as_ref(), |stack, source| {
-                        stack.child(
-                            div()
-                                .child(SharedString::from(source.clone()))
-                                .text_color(cx.theme().colors().text_muted),
-                        )
-                    }),
+                        },
+                    )),
             )
             .into_any_element()
     })

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

@@ -1757,6 +1757,7 @@ impl<'a> BlockChunks<'a> {
 pub struct StickyHeaderExcerpt<'a> {
     pub excerpt: &'a ExcerptInfo,
     pub next_excerpt_controls_present: bool,
+    // TODO az remove option
     pub next_buffer_row: Option<u32>,
 }
 

crates/editor/src/editor.rs 🔗

@@ -992,12 +992,14 @@ pub(crate) struct FocusedBlock {
 }
 
 #[derive(Clone)]
-struct JumpData {
-    excerpt_id: ExcerptId,
-    position: Point,
-    anchor: text::Anchor,
-    path: Option<project::ProjectPath>,
-    line_offset_from_top: u32,
+enum JumpData {
+    MultiBufferRow(MultiBufferRow),
+    MultiBufferPoint {
+        excerpt_id: ExcerptId,
+        position: Point,
+        anchor: text::Anchor,
+        line_offset_from_top: u32,
+    },
 }
 
 impl Editor {
@@ -12458,28 +12460,46 @@ impl Editor {
 
         let mut new_selections_by_buffer = HashMap::default();
         match &jump_data {
-            Some(jump_data) => {
+            Some(JumpData::MultiBufferPoint {
+                excerpt_id,
+                position,
+                anchor,
+                line_offset_from_top,
+            }) => {
                 let multi_buffer_snapshot = self.buffer.read(cx).snapshot(cx);
                 if let Some(buffer) = multi_buffer_snapshot
-                    .buffer_id_for_excerpt(jump_data.excerpt_id)
+                    .buffer_id_for_excerpt(*excerpt_id)
                     .and_then(|buffer_id| self.buffer.read(cx).buffer(buffer_id))
                 {
                     let buffer_snapshot = buffer.read(cx).snapshot();
-                    let jump_to_point = if buffer_snapshot.can_resolve(&jump_data.anchor) {
-                        language::ToPoint::to_point(&jump_data.anchor, &buffer_snapshot)
+                    let jump_to_point = if buffer_snapshot.can_resolve(anchor) {
+                        language::ToPoint::to_point(anchor, &buffer_snapshot)
                     } else {
-                        buffer_snapshot.clip_point(jump_data.position, Bias::Left)
+                        buffer_snapshot.clip_point(*position, Bias::Left)
                     };
                     let jump_to_offset = buffer_snapshot.point_to_offset(jump_to_point);
                     new_selections_by_buffer.insert(
                         buffer,
                         (
                             vec![jump_to_offset..jump_to_offset],
-                            Some(jump_data.line_offset_from_top),
+                            Some(*line_offset_from_top),
                         ),
                     );
                 }
             }
+            Some(JumpData::MultiBufferRow(row)) => {
+                let point = MultiBufferPoint::new(row.0, 0);
+                if let Some((buffer, buffer_point, _)) =
+                    self.buffer.read(cx).point_to_buffer_point(point, cx)
+                {
+                    let buffer_offset = buffer.read(cx).point_to_offset(buffer_point);
+                    new_selections_by_buffer
+                        .entry(buffer)
+                        .or_insert((Vec::new(), None))
+                        .0
+                        .push(buffer_offset..buffer_offset)
+                }
+            }
             None => {
                 let selections = self.selections.all::<usize>(cx);
                 let buffer = self.buffer.read(cx);

crates/editor/src/element.rs 🔗

@@ -52,10 +52,7 @@ use multi_buffer::{
     Anchor, AnchorRangeExt, ExcerptId, ExcerptInfo, ExpandExcerptDirection, MultiBufferPoint,
     MultiBufferRow, MultiBufferSnapshot, ToOffset,
 };
-use project::{
-    project_settings::{GitGutterSetting, ProjectSettings},
-    ProjectPath,
-};
+use project::project_settings::{GitGutterSetting, ProjectSettings};
 use settings::Settings;
 use smallvec::{smallvec, SmallVec};
 use std::{
@@ -69,12 +66,13 @@ use std::{
     sync::Arc,
 };
 use sum_tree::Bias;
+use text::BufferId;
 use theme::{ActiveTheme, Appearance, PlayerColor};
-use ui::{h_flex, ButtonLike, ButtonStyle, ContextMenu, Tooltip};
-use ui::{prelude::*, POPOVER_Y_PADDING};
+use ui::{
+    prelude::*, ButtonLike, ButtonStyle, ContextMenu, KeyBinding, Tooltip, POPOVER_Y_PADDING,
+};
 use unicode_segmentation::UnicodeSegmentation;
-use util::RangeExt;
-use util::ResultExt;
+use util::{RangeExt, ResultExt};
 use workspace::{item::Item, Workspace};
 
 struct SelectionLayout {
@@ -504,6 +502,7 @@ impl EditorElement {
         )
     }
 
+    #[allow(clippy::too_many_arguments)]
     fn mouse_left_down(
         editor: &mut Editor,
         event: &MouseDownEvent,
@@ -511,6 +510,7 @@ impl EditorElement {
         position_map: &PositionMap,
         text_hitbox: &Hitbox,
         gutter_hitbox: &Hitbox,
+        line_numbers: &HashMap<MultiBufferRow, (ShapedLine, Option<Hitbox>)>,
         cx: &mut ViewContext<Editor>,
     ) {
         if cx.default_prevented() {
@@ -530,7 +530,9 @@ impl EditorElement {
             return;
         }
 
-        if click_count == 2 && !editor.buffer().read(cx).is_singleton() {
+        let is_singleton = editor.buffer().read(cx).is_singleton();
+
+        if click_count == 2 && !is_singleton {
             match EditorSettings::get_global(cx).double_click_in_multibuffer {
                 DoubleClickInMultibuffer::Select => {
                     // do nothing special on double click, all selection logic is below
@@ -585,8 +587,27 @@ impl EditorElement {
                 cx,
             );
         }
-
         cx.stop_propagation();
+
+        if !is_singleton {
+            let display_row = (((event.position - gutter_hitbox.bounds.origin).y
+                + position_map.scroll_pixel_position.y)
+                / position_map.line_height) as u32;
+            let multi_buffer_row = position_map
+                .snapshot
+                .display_point_to_point(DisplayPoint::new(DisplayRow(display_row), 0), Bias::Right)
+                .row;
+            if let Some((_, Some(hitbox))) = line_numbers.get(&MultiBufferRow(multi_buffer_row)) {
+                if hitbox.contains(&event.position) {
+                    editor.open_excerpts_common(
+                        Some(JumpData::MultiBufferRow(MultiBufferRow(multi_buffer_row))),
+                        modifiers.alt,
+                        cx,
+                    );
+                    cx.stop_propagation();
+                }
+            }
+        }
     }
 
     fn mouse_right_down(
@@ -1975,20 +1996,25 @@ impl EditorElement {
         relative_rows
     }
 
+    #[allow(clippy::too_many_arguments)]
     fn layout_line_numbers(
         &self,
+        gutter_hitbox: Option<&Hitbox>,
+        gutter_dimensions: GutterDimensions,
+        line_height: Pixels,
+        scroll_position: gpui::Point<f32>,
         rows: Range<DisplayRow>,
         buffer_rows: impl Iterator<Item = Option<MultiBufferRow>>,
         active_rows: &BTreeMap<DisplayRow, bool>,
         newest_selection_head: Option<DisplayPoint>,
         snapshot: &EditorSnapshot,
         cx: &mut WindowContext,
-    ) -> Vec<Option<ShapedLine>> {
+    ) -> Arc<HashMap<MultiBufferRow, (ShapedLine, Option<Hitbox>)>> {
         let include_line_numbers = snapshot.show_line_numbers.unwrap_or_else(|| {
             EditorSettings::get_global(cx).gutter.line_numbers && snapshot.mode == EditorMode::Full
         });
         if !include_line_numbers {
-            return Vec::new();
+            return Arc::default();
         }
 
         let (newest_selection_head, is_relative) = self.editor.update(cx, |editor, cx| {
@@ -2008,7 +2034,6 @@ impl EditorElement {
             let is_relative = editor.should_use_relative_line_numbers(cx);
             (newest_selection_head, is_relative)
         });
-        let font_size = self.style.text.font_size.to_pixels(cx.rem_size());
 
         let relative_to = if is_relative {
             Some(newest_selection_head.row())
@@ -2017,11 +2042,11 @@ impl EditorElement {
         };
         let relative_rows = self.calculate_relative_line_numbers(snapshot, &rows, relative_to);
         let mut line_number = String::new();
-        buffer_rows
+        let line_numbers = buffer_rows
             .into_iter()
             .enumerate()
-            .map(|(ix, multibuffer_row)| {
-                let multibuffer_row = multibuffer_row?;
+            .flat_map(|(ix, buffer_row)| {
+                let buffer_row = buffer_row?;
                 let display_row = DisplayRow(rows.start.0 + ix as u32);
                 let color = if active_rows.contains_key(&display_row) {
                     cx.theme().colors().editor_active_line_number
@@ -2029,26 +2054,43 @@ impl EditorElement {
                     cx.theme().colors().editor_line_number
                 };
                 line_number.clear();
-                let default_number = multibuffer_row.0 + 1;
+                let default_number = buffer_row.0 + 1;
                 let number = relative_rows
                     .get(&DisplayRow(ix as u32 + rows.start.0))
                     .unwrap_or(&default_number);
                 write!(&mut line_number, "{number}").unwrap();
-                let run = TextRun {
-                    len: line_number.len(),
-                    font: self.style.text.font(),
-                    color,
-                    background_color: None,
-                    underline: None,
-                    strikethrough: None,
+
+                let shaped_line = self
+                    .shape_line_number(SharedString::from(&line_number), color, cx)
+                    .log_err()?;
+                let scroll_top = scroll_position.y * line_height;
+                let line_origin = gutter_hitbox.map(|hitbox| {
+                    hitbox.origin
+                        + point(
+                            hitbox.size.width - shaped_line.width - gutter_dimensions.right_padding,
+                            ix as f32 * line_height - (scroll_top % line_height),
+                        )
+                });
+
+                #[cfg(not(test))]
+                let hitbox = line_origin.map(|line_origin| {
+                    cx.insert_hitbox(
+                        Bounds::new(line_origin, size(shaped_line.width, line_height)),
+                        false,
+                    )
+                });
+                #[cfg(test)]
+                let hitbox = {
+                    let _ = line_origin;
+                    None
                 };
-                let shaped_line = cx
-                    .text_system()
-                    .shape_line(line_number.clone().into(), font_size, &[run])
-                    .unwrap();
-                Some(shaped_line)
+
+                let multi_buffer_row = DisplayPoint::new(display_row, 0).to_point(snapshot).row;
+                let multi_buffer_row = MultiBufferRow(multi_buffer_row);
+                Some((multi_buffer_row, (shaped_line, hitbox)))
             })
-            .collect()
+            .collect();
+        Arc::new(line_numbers)
     }
 
     fn layout_crease_toggles(
@@ -2209,6 +2251,7 @@ impl EditorElement {
         scroll_width: &mut Pixels,
         resized_blocks: &mut HashMap<CustomBlockId, u32>,
         selections: &[Selection<Point>],
+        selected_buffer_ids: &Vec<BufferId>,
         is_row_soft_wrapped: impl Copy + Fn(usize) -> bool,
         sticky_header_excerpt_id: Option<ExcerptId>,
         cx: &mut WindowContext,
@@ -2268,46 +2311,43 @@ impl EditorElement {
                 show_excerpt_controls,
                 height,
             } => {
-                let block_start = DisplayPoint::new(block_row_start, 0).to_point(snapshot);
-                let block_end = DisplayPoint::new(block_row_start + *height, 0).to_point(snapshot);
-                let selected = selections
-                    .binary_search_by(|selection| {
-                        if selection.end <= block_start {
-                            Ordering::Less
-                        } else if selection.start >= block_end {
-                            Ordering::Greater
-                        } else {
-                            Ordering::Equal
-                        }
-                    })
-                    .is_ok();
+                let selected = selected_buffer_ids.contains(&first_excerpt.buffer_id);
                 let icon_offset = gutter_dimensions.width
                     - (gutter_dimensions.left_padding + gutter_dimensions.margin);
-
                 let mut result = v_flex().id(block_id).w_full();
+
                 if let Some(prev_excerpt) = prev_excerpt {
                     if *show_excerpt_controls {
                         result = result.child(
                             h_flex()
+                                .id("expand_down_hit_area")
                                 .w(icon_offset)
                                 .h(MULTI_BUFFER_EXCERPT_HEADER_HEIGHT as f32 * cx.line_height())
                                 .flex_none()
                                 .justify_end()
                                 .child(self.render_expand_excerpt_button(
-                                    prev_excerpt.id,
-                                    ExpandExcerptDirection::Down,
                                     IconName::ArrowDownFromLine,
+                                    None,
                                     cx,
-                                )),
+                                ))
+                                .on_click(cx.listener_for(&self.editor, {
+                                    let excerpt_id = prev_excerpt.id;
+                                    let direction = ExpandExcerptDirection::Down;
+                                    move |editor, _, cx| {
+                                        editor.expand_excerpt(excerpt_id, direction, cx);
+                                        cx.stop_propagation();
+                                    }
+                                })),
                         );
                     }
                 }
 
-                let jump_data = jump_data(snapshot, block_row_start, *height, first_excerpt, cx);
+                let jump_data = header_jump_data(snapshot, block_row_start, *height, first_excerpt);
                 result
                     .child(self.render_buffer_header(first_excerpt, true, selected, jump_data, cx))
                     .into_any_element()
             }
+
             Block::ExcerptBoundary {
                 prev_excerpt,
                 next_excerpt,
@@ -2317,34 +2357,76 @@ impl EditorElement {
             } => {
                 let icon_offset = gutter_dimensions.width
                     - (gutter_dimensions.left_padding + gutter_dimensions.margin);
+                let header_height = MULTI_BUFFER_EXCERPT_HEADER_HEIGHT as f32 * cx.line_height();
+                let color = cx.theme().colors().clone();
+                let hover_color = color.border_variant.opacity(0.5);
+                let focus_handle = self.editor.focus_handle(cx).clone();
 
                 let mut result = v_flex().id(block_id).w_full();
+                let expand_area = |id: SharedString| {
+                    h_flex()
+                        .id(id)
+                        .w_full()
+                        .cursor_pointer()
+                        .block_mouse_down()
+                        .on_mouse_move(|_, cx| cx.stop_propagation())
+                        .hover(|style| style.bg(hover_color))
+                        .tooltip({
+                            let focus_handle = focus_handle.clone();
+                            move |cx| {
+                                Tooltip::for_action_in(
+                                    "Expand Excerpt",
+                                    &ExpandExcerpts { lines: 0 },
+                                    &focus_handle,
+                                    cx,
+                                )
+                            }
+                        })
+                };
+
                 if let Some(prev_excerpt) = prev_excerpt {
                     if *show_excerpt_controls {
+                        let group_name = "expand-down";
+
                         result = result.child(
-                            h_flex()
-                                .w(icon_offset)
-                                .h(MULTI_BUFFER_EXCERPT_HEADER_HEIGHT as f32 * cx.line_height())
-                                .flex_none()
-                                .justify_end()
-                                .child(self.render_expand_excerpt_button(
-                                    prev_excerpt.id,
-                                    ExpandExcerptDirection::Down,
-                                    IconName::ArrowDownFromLine,
-                                    cx,
-                                )),
+                            expand_area(format!("block-{}-down", block_id).into())
+                                .group(group_name)
+                                .child(
+                                    h_flex()
+                                        .w(icon_offset)
+                                        .h(header_height)
+                                        .flex_none()
+                                        .justify_end()
+                                        .child(self.render_expand_excerpt_button(
+                                            IconName::ArrowDownFromLine,
+                                            Some(group_name.to_string()),
+                                            cx,
+                                        )),
+                                )
+                                .on_click(cx.listener_for(&self.editor, {
+                                    let excerpt_id = prev_excerpt.id;
+                                    let direction = ExpandExcerptDirection::Down;
+                                    move |editor, _, cx| {
+                                        editor.expand_excerpt(excerpt_id, direction, cx);
+                                        cx.stop_propagation();
+                                    }
+                                })),
                         );
                     }
                 }
 
                 if let Some(next_excerpt) = next_excerpt {
-                    let jump_data = jump_data(snapshot, block_row_start, *height, next_excerpt, cx);
+                    let jump_data =
+                        header_jump_data(snapshot, block_row_start, *height, next_excerpt);
+
                     if *starts_new_buffer {
                         if sticky_header_excerpt_id != Some(next_excerpt.id) {
+                            let selected = selected_buffer_ids.contains(&next_excerpt.buffer_id);
+
                             result = result.child(self.render_buffer_header(
                                 next_excerpt,
                                 false,
-                                false,
+                                selected,
                                 jump_data,
                                 cx,
                             ));
@@ -2354,125 +2436,103 @@ impl EditorElement {
                         }
 
                         if *show_excerpt_controls {
+                            let group_name = "expand-up-first";
+
                             result = result.child(
-                                h_flex()
-                                    .w(icon_offset)
-                                    .h(MULTI_BUFFER_EXCERPT_HEADER_HEIGHT as f32 * cx.line_height())
-                                    .flex_none()
-                                    .justify_end()
-                                    .child(self.render_expand_excerpt_button(
-                                        next_excerpt.id,
-                                        ExpandExcerptDirection::Up,
-                                        IconName::ArrowUpFromLine,
-                                        cx,
-                                    )),
+                                h_flex().group(group_name).child(
+                                    expand_area(format!("block-{}-up-first", block_id).into())
+                                        .h(header_height)
+                                        .child(
+                                            h_flex()
+                                                .w(icon_offset)
+                                                .h(header_height)
+                                                .flex_none()
+                                                .justify_end()
+                                                .child(self.render_expand_excerpt_button(
+                                                    IconName::ArrowUpFromLine,
+                                                    Some(group_name.to_string()),
+                                                    cx,
+                                                )),
+                                        )
+                                        .on_click(cx.listener_for(&self.editor, {
+                                            let excerpt_id = next_excerpt.id;
+                                            let direction = ExpandExcerptDirection::Up;
+                                            move |editor, _, cx| {
+                                                editor.expand_excerpt(excerpt_id, direction, cx);
+                                                cx.stop_propagation();
+                                            }
+                                        })),
+                                ),
                             );
                         }
                     } else {
-                        let editor = self.editor.clone();
-                        result = result.child(
-                            h_flex()
-                                .id("excerpt header block")
-                                .group("excerpt-jump-action")
-                                .justify_start()
-                                .w_full()
-                                .h(MULTI_BUFFER_EXCERPT_HEADER_HEIGHT as f32 * cx.line_height())
-                                .relative()
-                                .child(
-                                    div()
-                                        .top(px(0.))
-                                        .absolute()
-                                        .w_full()
-                                        .h_px()
-                                        .bg(cx.theme().colors().border_variant)
-                                        .group_hover("excerpt-jump-action", |style| {
-                                            style.bg(cx.theme().colors().border)
-                                        }),
-                                )
-                                .cursor_pointer()
-                                .on_click({
-                                    let jump_data = jump_data.clone();
-                                    cx.listener_for(&self.editor, {
-                                        let jump_data = jump_data.clone();
-                                        move |editor, e: &ClickEvent, cx| {
-                                            cx.stop_propagation();
-                                            editor.open_excerpts_common(
-                                                Some(jump_data.clone()),
-                                                e.down.modifiers.secondary(),
-                                                cx,
-                                            );
-                                        }
-                                    })
-                                })
-                                .tooltip({
-                                    let jump_data = jump_data.clone();
-                                    move |cx| {
-                                        let jump_message = format!(
-                                            "Jump to {}:L{}",
-                                            match &jump_data.path {
-                                                Some(project_path) =>
-                                                    project_path.path.display().to_string(),
-                                                None => {
-                                                    let editor = editor.read(cx);
-                                                    editor
-                                                        .file_at(jump_data.position, cx)
-                                                        .map(|file| {
-                                                            file.full_path(cx).display().to_string()
-                                                        })
-                                                        .or_else(|| {
-                                                            Some(
-                                                                editor
-                                                                    .tab_description(0, cx)?
-                                                                    .to_string(),
+                        let group_name = "expand-up-subsequent";
+
+                        if *show_excerpt_controls {
+                            result = result.child(
+                                h_flex()
+                                    .relative()
+                                    .group(group_name)
+                                    .child(
+                                        div()
+                                            .top(px(0.))
+                                            .absolute()
+                                            .w_full()
+                                            .h_px()
+                                            .bg(color.border_variant),
+                                    )
+                                    .child(
+                                        expand_area(format!("block-{}-up", block_id).into())
+                                            .h(header_height)
+                                            .child(
+                                                h_flex()
+                                                    .w(icon_offset)
+                                                    .h(header_height)
+                                                    .flex_none()
+                                                    .justify_end()
+                                                    .child(if *show_excerpt_controls {
+                                                        self.render_expand_excerpt_button(
+                                                            IconName::ArrowUpFromLine,
+                                                            Some(group_name.to_string()),
+                                                            cx,
+                                                        )
+                                                    } else {
+                                                        ButtonLike::new("jump-icon")
+                                                            .style(ButtonStyle::Transparent)
+                                                            .child(
+                                                                svg()
+                                                                    .path(
+                                                                        IconName::ArrowUpRight
+                                                                            .path(),
+                                                                    )
+                                                                    .size(IconSize::XSmall.rems())
+                                                                    .text_color(
+                                                                        color.border_variant,
+                                                                    )
+                                                                    .group_hover(
+                                                                        group_name,
+                                                                        |style| {
+                                                                            style.text_color(
+                                                                                color.border,
+                                                                            )
+                                                                        },
+                                                                    ),
                                                             )
-                                                        })
-                                                        .unwrap_or_else(|| {
-                                                            "Unknown buffer".to_string()
-                                                        })
-                                                }
-                                            },
-                                            jump_data.position.row + 1
-                                        );
-                                        Tooltip::for_action(jump_message, &OpenExcerpts, cx)
-                                    }
-                                })
-                                .child(
-                                    h_flex()
-                                        .w(icon_offset)
-                                        .h(MULTI_BUFFER_EXCERPT_HEADER_HEIGHT as f32
-                                            * cx.line_height())
-                                        .flex_none()
-                                        .justify_end()
-                                        .child(if *show_excerpt_controls {
-                                            self.render_expand_excerpt_button(
-                                                next_excerpt.id,
-                                                ExpandExcerptDirection::Up,
-                                                IconName::ArrowUpFromLine,
-                                                cx,
+                                                    }),
                                             )
-                                        } 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_hover(
-                                                            "excerpt-jump-action",
-                                                            |style| {
-                                                                style.text_color(
-                                                                    cx.theme().colors().border,
-                                                                )
-                                                            },
-                                                        ),
-                                                )
-                                        }),
-                                ),
-                        );
-                    }
+                                            .on_click(cx.listener_for(&self.editor, {
+                                                let excerpt_id = next_excerpt.id;
+                                                let direction = ExpandExcerptDirection::Up;
+                                                move |editor, _, cx| {
+                                                    editor
+                                                        .expand_excerpt(excerpt_id, direction, cx);
+                                                    cx.stop_propagation();
+                                                }
+                                            })),
+                                    ),
+                            );
+                        }
+                    };
                 }
 
                 result.into_any()
@@ -2524,8 +2584,8 @@ impl EditorElement {
         let parent_path = path
             .as_ref()
             .and_then(|path| Some(path.parent()?.to_string_lossy().to_string() + "/"));
-
         let focus_handle = self.editor.focus_handle(cx);
+        let colors = cx.theme().colors();
 
         div()
             .px_2()
@@ -2538,20 +2598,20 @@ impl EditorElement {
                     .gap_2()
                     .flex_basis(Length::Definite(DefiniteLength::Fraction(0.667)))
                     .pl_0p5()
-                    .pr_4()
+                    .pr_5()
                     .rounded_md()
                     .shadow_md()
                     .border_1()
                     .map(|div| {
-                        let border_color = if is_selected {
-                            cx.theme().colors().border_focused
+                        let border_color = if is_selected && is_folded {
+                            colors.border_focused
                         } else {
-                            cx.theme().colors().border
+                            colors.border
                         };
                         div.border_color(border_color)
                     })
-                    .bg(cx.theme().colors().editor_subheader_background)
-                    .hover(|style| style.bg(cx.theme().colors().element_hover))
+                    .bg(colors.editor_subheader_background)
+                    .hover(|style| style.bg(colors.element_hover))
                     .map(|header| {
                         let editor = self.editor.clone();
                         let buffer_id = for_excerpt.buffer_id;
@@ -2559,7 +2619,7 @@ impl EditorElement {
                             FileIcons::get_chevron_icon(!is_folded, cx).map(Icon::from_path);
                         header.child(
                             div()
-                                .hover(|style| style.bg(cx.theme().colors().element_selected))
+                                .hover(|style| style.bg(colors.element_selected))
                                 .rounded_sm()
                                 .child(
                                     ButtonLike::new("toggle-buffer-fold")
@@ -2594,6 +2654,7 @@ impl EditorElement {
                     })
                     .child(
                         h_flex()
+                            .cursor_pointer()
                             .id("path header block")
                             .size_full()
                             .justify_between()
@@ -2606,25 +2667,24 @@ impl EditorElement {
                                             .unwrap_or_else(|| "untitled".into()),
                                     )
                                     .when_some(parent_path, |then, path| {
-                                        then.child(
-                                            div()
-                                                .child(path)
-                                                .text_color(cx.theme().colors().text_muted),
-                                        )
+                                        then.child(div().child(path).text_color(colors.text_muted))
                                     }),
                             )
-                            .child(Icon::new(IconName::ArrowUpRight).size(IconSize::Small))
-                            .cursor_pointer()
-                            .tooltip({
-                                let focus_handle = focus_handle.clone();
-                                move |cx| {
-                                    Tooltip::for_action_in(
-                                        "Jump To File",
-                                        &OpenExcerpts,
-                                        &focus_handle,
-                                        cx,
-                                    )
-                                }
+                            .when(is_selected, |el| {
+                                el.child(
+                                    h_flex()
+                                        .id("jump-to-file-button")
+                                        .gap_2p5()
+                                        .child(Label::new("Jump To File"))
+                                        .children(
+                                            KeyBinding::for_action_in(
+                                                &OpenExcerpts,
+                                                &focus_handle,
+                                                cx,
+                                            )
+                                            .map(|binding| binding.into_any_element()),
+                                        ),
+                                )
                             })
                             .on_mouse_down(MouseButton::Left, |_, cx| cx.stop_propagation())
                             .on_click(cx.listener_for(&self.editor, {
@@ -2642,11 +2702,11 @@ impl EditorElement {
 
     fn render_expand_excerpt_button(
         &self,
-        excerpt_id: ExcerptId,
-        direction: ExpandExcerptDirection,
         icon: IconName,
+        group_name: impl Into<Option<String>>,
         cx: &mut WindowContext,
     ) -> ButtonLike {
+        let group_name = group_name.into();
         ButtonLike::new("expand-icon")
             .style(ButtonStyle::Transparent)
             .child(
@@ -2654,17 +2714,12 @@ impl EditorElement {
                     .path(icon.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)),
+                    .when_some(group_name, |svg, group_name| {
+                        svg.group_hover(group_name, |style| {
+                            style.text_color(cx.theme().colors().editor_active_line_number)
+                        })
+                    }),
             )
-            .on_click(cx.listener_for(&self.editor, {
-                move |editor, _, cx| {
-                    editor.expand_excerpt(excerpt_id, direction, cx);
-                }
-            }))
-            .tooltip({
-                move |cx| Tooltip::for_action("Expand Excerpt", &ExpandExcerpts { lines: 0 }, cx)
-            })
     }
 
     #[allow(clippy::too_many_arguments)]
@@ -2682,6 +2737,7 @@ impl EditorElement {
         line_height: Pixels,
         line_layouts: &[LineWithInvisibles],
         selections: &[Selection<Point>],
+        selected_buffer_ids: &Vec<BufferId>,
         is_row_soft_wrapped: impl Copy + Fn(usize) -> bool,
         sticky_header_excerpt_id: Option<ExcerptId>,
         cx: &mut WindowContext,
@@ -2721,6 +2777,7 @@ impl EditorElement {
                 scroll_width,
                 &mut resized_blocks,
                 selections,
+                selected_buffer_ids,
                 is_row_soft_wrapped,
                 sticky_header_excerpt_id,
                 cx,
@@ -2769,6 +2826,7 @@ impl EditorElement {
                 scroll_width,
                 &mut resized_blocks,
                 selections,
+                selected_buffer_ids,
                 is_row_soft_wrapped,
                 sticky_header_excerpt_id,
                 cx,
@@ -2817,6 +2875,7 @@ impl EditorElement {
                             scroll_width,
                             &mut resized_blocks,
                             selections,
+                            selected_buffer_ids,
                             is_row_soft_wrapped,
                             sticky_header_excerpt_id,
                             cx,
@@ -2885,6 +2944,7 @@ impl EditorElement {
         }
     }
 
+    #[allow(clippy::too_many_arguments)]
     fn layout_sticky_buffer_header(
         &self,
         StickyHeaderExcerpt {
@@ -2896,12 +2956,15 @@ impl EditorElement {
         line_height: Pixels,
         snapshot: &EditorSnapshot,
         hitbox: &Hitbox,
+        selected_buffer_ids: &Vec<BufferId>,
         cx: &mut WindowContext,
     ) -> AnyElement {
-        let jump_data = jump_data(snapshot, DisplayRow(0), FILE_HEADER_HEIGHT, excerpt, cx);
+        let jump_data = header_jump_data(snapshot, DisplayRow(0), FILE_HEADER_HEIGHT, excerpt);
 
         let editor_bg_color = cx.theme().colors().editor_background;
 
+        let selected = selected_buffer_ids.contains(&excerpt.buffer_id);
+
         let mut header = v_flex()
             .relative()
             .child(
@@ -2917,7 +2980,7 @@ impl EditorElement {
                     .top_0(),
             )
             .child(
-                self.render_buffer_header(excerpt, false, false, jump_data, cx)
+                self.render_buffer_header(excerpt, false, selected, jump_data, cx)
                     .into_any_element(),
             )
             .into_any_element();
@@ -3860,24 +3923,30 @@ impl EditorElement {
     }
 
     fn paint_line_numbers(&mut self, layout: &mut EditorLayout, cx: &mut WindowContext) {
-        let line_height = layout.position_map.line_height;
-        let scroll_position = layout.position_map.snapshot.scroll_position();
-        let scroll_top = scroll_position.y * line_height;
+        let is_singleton = self.editor.read(cx).is_singleton(cx);
 
+        let line_height = layout.position_map.line_height;
         cx.set_cursor_style(CursorStyle::Arrow, &layout.gutter_hitbox);
 
-        for (ix, line) in layout.line_numbers.iter().enumerate() {
-            if let Some(line) = line {
-                let line_origin = layout.gutter_hitbox.origin
-                    + point(
-                        layout.gutter_hitbox.size.width
-                            - line.width
-                            - layout.gutter_dimensions.right_padding,
-                        ix as f32 * line_height - (scroll_top % line_height),
-                    );
-
-                line.paint(line_origin, line_height, cx).log_err();
-            }
+        for (_, (line, hitbox)) in layout.line_numbers.iter() {
+            let Some(hitbox) = hitbox else {
+                continue;
+            };
+            let color = if !is_singleton && hitbox.is_hovered(cx) {
+                cx.theme().colors().editor_active_line_number
+            } else {
+                cx.theme().colors().editor_line_number
+            };
+            let Some(line) = self
+                .shape_line_number(line.text.clone(), color, cx)
+                .log_err()
+            else {
+                continue;
+            };
+            let Some(()) = line.paint(hitbox.origin, line_height, cx).log_err() else {
+                continue;
+            };
+            cx.set_cursor_style(CursorStyle::PointingHand, hitbox);
         }
     }
 
@@ -4876,6 +4945,7 @@ impl EditorElement {
             let editor = self.editor.clone();
             let text_hitbox = layout.text_hitbox.clone();
             let gutter_hitbox = layout.gutter_hitbox.clone();
+            let line_numbers = layout.line_numbers.clone();
 
             move |event: &MouseDownEvent, phase, cx| {
                 if phase == DispatchPhase::Bubble {
@@ -4888,6 +4958,7 @@ impl EditorElement {
                                 &position_map,
                                 &text_hitbox,
                                 &gutter_hitbox,
+                                line_numbers.as_ref(),
                                 cx,
                             );
                         }),
@@ -4984,21 +5055,37 @@ impl EditorElement {
         let digit_count = (snapshot.widest_line_number() as f32).log10().floor() as usize + 1;
         self.column_pixels(digit_count, cx)
     }
+
+    fn shape_line_number(
+        &self,
+        text: SharedString,
+        color: Hsla,
+        cx: &WindowContext,
+    ) -> anyhow::Result<ShapedLine> {
+        let run = TextRun {
+            len: text.len(),
+            font: self.style.text.font(),
+            color,
+            background_color: None,
+            underline: None,
+            strikethrough: None,
+        };
+        cx.text_system().shape_line(
+            text,
+            self.style.text.font_size.to_pixels(cx.rem_size()),
+            &[run],
+        )
+    }
 }
 
-fn jump_data(
+fn header_jump_data(
     snapshot: &EditorSnapshot,
     block_row_start: DisplayRow,
     height: u32,
     for_excerpt: &ExcerptInfo,
-    cx: &mut WindowContext,
 ) -> JumpData {
     let range = &for_excerpt.range;
     let buffer = &for_excerpt.buffer;
-    let jump_path = project::File::from_dyn(buffer.file()).map(|file| ProjectPath {
-        worktree_id: file.worktree_id(cx),
-        path: file.path.clone(),
-    });
     let jump_anchor = range
         .primary
         .as_ref()
@@ -5012,6 +5099,7 @@ 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.saturating_sub(
@@ -5020,11 +5108,11 @@ fn jump_data(
                 .scroll_position(&snapshot.display_snapshot)
                 .y as u32,
         );
-    JumpData {
+
+    JumpData::MultiBufferPoint {
         excerpt_id: for_excerpt.id,
         anchor: jump_anchor,
         position: language::ToPoint::to_point(&jump_anchor, buffer),
-        path: jump_path,
         line_offset_from_top,
     }
 }
@@ -6079,14 +6167,37 @@ impl Element for EditorElement {
                         cx,
                     );
 
-                    let local_selections: Vec<Selection<Point>> =
-                        self.editor.update(cx, |editor, cx| {
-                            let mut selections = editor
-                                .selections
-                                .disjoint_in_range(start_anchor..end_anchor, cx);
-                            selections.extend(editor.selections.pending(cx));
-                            selections
-                        });
+                    let (local_selections, selected_buffer_ids): (
+                        Vec<Selection<Point>>,
+                        Vec<BufferId>,
+                    ) = self.editor.update(cx, |editor, cx| {
+                        let all_selections = editor.selections.all::<Point>(cx);
+                        let selected_buffer_ids = if editor.is_singleton(cx) {
+                            Vec::new()
+                        } else {
+                            let mut selected_buffer_ids = Vec::with_capacity(all_selections.len());
+
+                            for selection in all_selections {
+                                for buffer_id in snapshot
+                                    .buffer_snapshot
+                                    .buffer_ids_in_selected_rows(selection)
+                                {
+                                    if selected_buffer_ids.last() != Some(&buffer_id) {
+                                        selected_buffer_ids.push(buffer_id);
+                                    }
+                                }
+                            }
+
+                            selected_buffer_ids
+                        };
+
+                        let mut selections = editor
+                            .selections
+                            .disjoint_in_range(start_anchor..end_anchor, cx);
+                        selections.extend(editor.selections.pending(cx));
+
+                        (selections, selected_buffer_ids)
+                    });
 
                     let (selections, active_rows, newest_selection_head) = self.layout_selections(
                         start_anchor,
@@ -6099,6 +6210,10 @@ impl Element for EditorElement {
                     );
 
                     let line_numbers = self.layout_line_numbers(
+                        Some(&gutter_hitbox),
+                        gutter_dimensions,
+                        line_height,
+                        scroll_position,
                         start_row..end_row,
                         buffer_rows.iter().copied(),
                         &active_rows,
@@ -6188,6 +6303,7 @@ impl Element for EditorElement {
                             line_height,
                             &line_layouts,
                             &local_selections,
+                            &selected_buffer_ids,
                             is_row_soft_wrapped,
                             sticky_header_excerpt_id,
                             cx,
@@ -6211,6 +6327,7 @@ impl Element for EditorElement {
                                 line_height,
                                 &snapshot,
                                 &hitbox,
+                                &selected_buffer_ids,
                                 cx,
                             )
                         })
@@ -6610,7 +6727,6 @@ impl Element for EditorElement {
                         hitbox,
                         text_hitbox,
                         gutter_hitbox,
-                        gutter_dimensions,
                         display_hunks,
                         content_origin,
                         scrollbars_layout,
@@ -6652,6 +6768,7 @@ impl Element for EditorElement {
     ) {
         let focus_handle = self.editor.focus_handle(cx);
         let key_context = self.editor.update(cx, |editor, cx| editor.key_context(cx));
+
         cx.set_key_context(key_context);
         cx.handle_input(
             &focus_handle,
@@ -6801,7 +6918,6 @@ pub struct EditorLayout {
     hitbox: Hitbox,
     text_hitbox: Hitbox,
     gutter_hitbox: Hitbox,
-    gutter_dimensions: GutterDimensions,
     content_origin: gpui::Point<Pixels>,
     scrollbars_layout: AxisPair<Option<ScrollbarLayout>>,
     mode: EditorMode,
@@ -6811,7 +6927,7 @@ pub struct EditorLayout {
     active_rows: BTreeMap<DisplayRow, bool>,
     highlighted_rows: BTreeMap<DisplayRow, Hsla>,
     line_elements: SmallVec<[AnyElement; 1]>,
-    line_numbers: Vec<Option<ShapedLine>>,
+    line_numbers: Arc<HashMap<MultiBufferRow, (ShapedLine, Option<Hitbox>)>>,
     display_hunks: Vec<(DisplayDiffHunk, Option<Hitbox>)>,
     blamed_display_rows: Option<Vec<AnyElement>>,
     inline_blame: Option<AnyElement>,

crates/multi_buffer/src/multi_buffer.rs 🔗

@@ -3449,6 +3449,24 @@ impl MultiBufferSnapshot {
         }
     }
 
+    pub fn buffer_ids_in_selected_rows(
+        &self,
+        selection: Selection<Point>,
+    ) -> impl Iterator<Item = BufferId> + '_ {
+        let mut cursor = self.excerpts.cursor::<Point>(&());
+        cursor.seek(&Point::new(selection.start.row, 0), Bias::Right, &());
+        cursor.prev(&());
+
+        iter::from_fn(move || {
+            cursor.next(&());
+            if cursor.start().row <= selection.end.row {
+                cursor.item().map(|item| item.buffer_id)
+            } else {
+                None
+            }
+        })
+    }
+
     pub fn excerpts(
         &self,
     ) -> impl Iterator<Item = (ExcerptId, &BufferSnapshot, ExcerptRange<text::Anchor>)> {

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

@@ -49,6 +49,7 @@ impl Tooltip {
         })
         .into()
     }
+
     pub fn with_meta(
         title: impl Into<SharedString>,
         action: Option<&dyn Action>,
@@ -63,6 +64,22 @@ impl Tooltip {
         .into()
     }
 
+    pub fn with_meta_in(
+        title: impl Into<SharedString>,
+        action: Option<&dyn Action>,
+        meta: impl Into<SharedString>,
+        focus_handle: &FocusHandle,
+        cx: &mut WindowContext,
+    ) -> AnyView {
+        cx.new_view(|cx| Self {
+            title: title.into(),
+            meta: Some(meta.into()),
+            key_binding: action
+                .and_then(|action| KeyBinding::for_action_in(action, focus_handle, cx)),
+        })
+        .into()
+    }
+
     pub fn new(title: impl Into<SharedString>) -> Self {
         Self {
             title: title.into(),