Allow folding buffers inside multi buffers (#22046)

Closes https://github.com/zed-industries/zed/issues/4925


https://github.com/user-attachments/assets/e7b87375-893f-41ae-a2d9-d501499e40d1


Allows to fold any buffer inside multi buffers, either by clicking the
chevron icon on the header, or by using
`editor::Fold`/`editor::UnfoldLines`/`editor::ToggleFold`/`editor::FoldAll`
and `editor::UnfoldAll` actions inside the multi buffer (those were noop
there before).

Every fold has a fake line inside it, so it's possible to navigate into
that via the keyboard and unfold it with the corresponding editor
action.

The state is synchronized with the outline panel state: any fold inside
multi buffer folds the corresponding file entry; any file entry fold
inside the outline panel folds the corresponding buffer inside the multi
buffer, any directory fold inside the outline panel folds the
corresponding buffers inside the multi buffer for each nested file entry
in the panel.


Release Notes:

- Added a possibility to fold buffers inside multi buffers

---------

Co-authored-by: Antonio Scandurra <antonio@zed.dev>
Co-authored-by: Max Brunsfeld <max@zed.dev>
Co-authored-by: Cole Miller <cole@zed.dev>
This commit is contained in:
Kirill Bulatov 2024-12-16 00:32:07 +02:00 committed by GitHub
parent f64fcedabb
commit af50261ae2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 2401 additions and 589 deletions

View file

@ -27,18 +27,18 @@ use crate::{
};
use client::ParticipantIndex;
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, AnchorCorner, AnyElement, AvailableSpace, Bounds, ClipboardItem,
ContentMask, Corners, CursorStyle, DispatchPhase, Edges, Element, ElementInputHandler, Entity,
FontId, GlobalElementId, HighlightStyle, Hitbox, Hsla, InteractiveElement, IntoElement, Length,
ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad,
ParentElement, Pixels, ScrollDelta, ScrollWheelEvent, ShapedLine, SharedString, Size,
StatefulInteractiveElement, Style, Styled, TextRun, TextStyleRefinement, View, ViewContext,
WeakView, WindowContext,
transparent_black, Action, AnchorCorner, AnyElement, AvailableSpace, Bounds, ClickEvent,
ClipboardItem, ContentMask, Corners, CursorStyle, DispatchPhase, Edges, Element,
ElementInputHandler, Entity, FontId, GlobalElementId, HighlightStyle, 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 gpui::{ClickEvent, Subscription};
use itertools::Itertools;
use language::{
language_settings::{
@ -49,8 +49,8 @@ use language::{
};
use lsp::DiagnosticSeverity;
use multi_buffer::{
Anchor, AnchorRangeExt, ExcerptId, ExpandExcerptDirection, MultiBufferPoint, MultiBufferRow,
MultiBufferSnapshot, ToOffset,
Anchor, AnchorRangeExt, ExcerptId, ExcerptInfo, ExpandExcerptDirection, MultiBufferPoint,
MultiBufferRow, MultiBufferSnapshot, ToOffset,
};
use project::{
project_settings::{GitGutterSetting, ProjectSettings},
@ -1713,6 +1713,15 @@ impl EditorElement {
}
let multibuffer_point = tasks.offset.0.to_point(&snapshot.buffer_snapshot);
let multibuffer_row = MultiBufferRow(multibuffer_point.row);
let buffer_folded = snapshot
.buffer_snapshot
.buffer_line_for_row(multibuffer_row)
.map(|(buffer_snapshot, _)| buffer_snapshot.remote_id())
.map(|buffer_id| editor.buffer_folded(buffer_id, cx))
.unwrap_or(false);
if buffer_folded {
return None;
}
if snapshot.is_line_folded(multibuffer_row) {
// Skip folded indicators, unless it's the starting line of a fold.
@ -2087,6 +2096,7 @@ impl EditorElement {
is_row_soft_wrapped: impl Copy + Fn(usize) -> bool,
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);
@ -2136,21 +2146,58 @@ impl EditorElement {
.into_any()
}
Block::ExcerptBoundary {
Block::FoldedBuffer {
first_excerpt,
prev_excerpt,
next_excerpt,
show_excerpt_controls,
starts_new_buffer,
height,
..
} => {
let icon_offset = gutter_dimensions.width
- (gutter_dimensions.left_padding + gutter_dimensions.margin);
let header_padding = px(6.0);
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()
.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,
)),
);
}
}
let jump_data = jump_data(snapshot, block_row_start, *height, first_excerpt, cx);
result
.child(self.render_buffer_header(
first_excerpt,
header_padding,
true,
jump_data,
cx,
))
.into_any_element()
}
Block::ExcerptBoundary {
prev_excerpt,
next_excerpt,
show_excerpt_controls,
height,
starts_new_buffer,
..
} => {
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(
@ -2170,115 +2217,15 @@ impl EditorElement {
}
if let Some(next_excerpt) = next_excerpt {
let buffer = &next_excerpt.buffer;
let range = &next_excerpt.range;
let jump_data = {
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()
.map_or(range.context.start, |primary| primary.start);
let excerpt_start = range.context.start;
let jump_position = language::ToPoint::to_point(&jump_anchor, buffer);
let offset_from_excerpt_start = if jump_anchor == excerpt_start {
0
} else {
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;
JumpData {
excerpt_id: next_excerpt.id,
anchor: jump_anchor,
position: language::ToPoint::to_point(&jump_anchor, buffer),
path: jump_path,
line_offset_from_top,
}
};
let jump_data = jump_data(snapshot, block_row_start, *height, next_excerpt, cx);
if *starts_new_buffer {
let include_root = self
.editor
.read(cx)
.project
.as_ref()
.map(|project| project.read(cx).visible_worktrees(cx).count() > 1)
.unwrap_or_default();
let path = buffer.resolve_file_path(cx, include_root);
let filename = path
.as_ref()
.and_then(|path| Some(path.file_name()?.to_string_lossy().to_string()));
let parent_path = path.as_ref().and_then(|path| {
Some(path.parent()?.to_string_lossy().to_string() + "/")
});
result = result.child(
div()
.px(header_padding)
.pt(header_padding)
.w_full()
.h(FILE_HEADER_HEIGHT as f32 * cx.line_height())
.child(
h_flex()
.id("path header block")
.size_full()
.flex_basis(Length::Definite(DefiniteLength::Fraction(
0.667,
)))
.px(gpui::px(12.))
.rounded_md()
.shadow_md()
.border_1()
.border_color(cx.theme().colors().border)
.bg(cx.theme().colors().editor_subheader_background)
.justify_between()
.hover(|style| style.bg(cx.theme().colors().element_hover))
.child(
h_flex().gap_3().child(
h_flex()
.gap_2()
.child(
filename
.map(SharedString::from)
.unwrap_or_else(|| "untitled".into()),
)
.when_some(parent_path, |then, path| {
then.child(div().child(path).text_color(
cx.theme().colors().text_muted,
))
}),
),
)
.child(Icon::new(IconName::ArrowUpRight))
.cursor_pointer()
.tooltip(|cx| {
Tooltip::for_action("Jump to File", &OpenExcerpts, cx)
})
.on_mouse_down(MouseButton::Left, |_, cx| {
cx.stop_propagation()
})
.on_click(cx.listener_for(&self.editor, {
move |editor, e: &ClickEvent, cx| {
editor.open_excerpts_common(
Some(jump_data.clone()),
e.down.modifiers.secondary(),
cx,
);
}
})),
),
);
result = result.child(self.render_buffer_header(
next_excerpt,
header_padding,
false,
jump_data,
cx,
));
if *show_excerpt_controls {
result = result.child(
h_flex()
@ -2428,6 +2375,105 @@ impl EditorElement {
(element, final_size)
}
fn render_buffer_header(
&self,
for_excerpt: &ExcerptInfo,
header_padding: Pixels,
is_folded: bool,
jump_data: JumpData,
cx: &mut WindowContext,
) -> Div {
let include_root = self
.editor
.read(cx)
.project
.as_ref()
.map(|project| project.read(cx).visible_worktrees(cx).count() > 1)
.unwrap_or_default();
let path = for_excerpt.buffer.resolve_file_path(cx, include_root);
let filename = path
.as_ref()
.and_then(|path| Some(path.file_name()?.to_string_lossy().to_string()));
let parent_path = path
.as_ref()
.and_then(|path| Some(path.parent()?.to_string_lossy().to_string() + "/"));
div()
.px(header_padding)
.pt(header_padding)
.w_full()
.h(FILE_HEADER_HEIGHT as f32 * cx.line_height())
.child(
h_flex()
.id("path header block")
.size_full()
.flex_basis(Length::Definite(DefiniteLength::Fraction(0.667)))
.px(gpui::px(12.))
.rounded_md()
.shadow_md()
.border_1()
.border_color(cx.theme().colors().border)
.bg(cx.theme().colors().editor_subheader_background)
.justify_between()
.hover(|style| style.bg(cx.theme().colors().element_hover))
.child(
h_flex()
.gap_3()
.map(|header| {
let editor = self.editor.clone();
let buffer_id = for_excerpt.buffer_id;
let toggle_chevron_icon =
FileIcons::get_chevron_icon(!is_folded, cx)
.map(Icon::from_path);
header.child(
ButtonLike::new("toggle-buffer-fold")
.children(toggle_chevron_icon)
.on_click(move |_, cx| {
if is_folded {
editor.update(cx, |editor, cx| {
editor.unfold_buffer(buffer_id, cx);
});
} else {
editor.update(cx, |editor, cx| {
editor.fold_buffer(buffer_id, cx);
});
}
}),
)
})
.child(
h_flex()
.gap_2()
.child(
filename
.map(SharedString::from)
.unwrap_or_else(|| "untitled".into()),
)
.when_some(parent_path, |then, path| {
then.child(
div()
.child(path)
.text_color(cx.theme().colors().text_muted),
)
}),
),
)
.child(Icon::new(IconName::ArrowUpRight))
.cursor_pointer()
.tooltip(|cx| Tooltip::for_action("Jump to File", &OpenExcerpts, cx))
.on_mouse_down(MouseButton::Left, |_, cx| cx.stop_propagation())
.on_click(cx.listener_for(&self.editor, {
move |editor, e: &ClickEvent, cx| {
editor.open_excerpts_common(
Some(jump_data.clone()),
e.down.modifiers.secondary(),
cx,
);
}
})),
)
}
fn render_expand_excerpt_button(
&self,
excerpt_id: ExcerptId,
@ -4314,6 +4360,46 @@ impl EditorElement {
}
}
fn 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()
.map_or(range.context.start, |primary| primary.start);
let excerpt_start = range.context.start;
let jump_position = language::ToPoint::to_point(&jump_anchor, buffer);
let offset_from_excerpt_start = if jump_anchor == excerpt_start {
0
} else {
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;
JumpData {
excerpt_id: for_excerpt.id,
anchor: jump_anchor,
position: language::ToPoint::to_point(&jump_anchor, buffer),
path: jump_path,
line_offset_from_top,
}
}
fn inline_completion_popover_text(
editor_snapshot: &EditorSnapshot,
edits: &Vec<(Range<Anchor>, String)>,
@ -5757,29 +5843,33 @@ impl Element for EditorElement {
if !expanded_add_hunks_by_rows
.contains_key(&newest_selection_display_row)
{
let buffer = snapshot.buffer_snapshot.buffer_line_for_row(
MultiBufferRow(newest_selection_point.row),
);
if let Some((buffer, range)) = buffer {
let buffer_id = buffer.remote_id();
let row = range.start.row;
let has_test_indicator = self
.editor
.read(cx)
.tasks
.contains_key(&(buffer_id, row));
if !snapshot
.is_line_folded(MultiBufferRow(newest_selection_point.row))
{
let buffer = snapshot.buffer_snapshot.buffer_line_for_row(
MultiBufferRow(newest_selection_point.row),
);
if let Some((buffer, range)) = buffer {
let buffer_id = buffer.remote_id();
let row = range.start.row;
let has_test_indicator = self
.editor
.read(cx)
.tasks
.contains_key(&(buffer_id, row));
if !has_test_indicator {
code_actions_indicator = self
.layout_code_actions_indicator(
line_height,
newest_selection_head,
scroll_pixel_position,
&gutter_dimensions,
&gutter_hitbox,
&rows_with_hunk_bounds,
cx,
);
if !has_test_indicator {
code_actions_indicator = self
.layout_code_actions_indicator(
line_height,
newest_selection_head,
scroll_pixel_position,
&gutter_dimensions,
&gutter_hitbox,
&rows_with_hunk_bounds,
cx,
);
}
}
}
}