From af50261ae240a2c44bc742f434be46032e47256e Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Mon, 16 Dec 2024 00:32:07 +0200 Subject: [PATCH] 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 Co-authored-by: Max Brunsfeld Co-authored-by: Cole Miller --- crates/diagnostics/src/diagnostics_tests.rs | 1 + crates/editor/src/display_map.rs | 48 +- crates/editor/src/display_map/block_map.rs | 1179 +++++++++++++++---- crates/editor/src/editor.rs | 285 +++-- crates/editor/src/editor_tests.rs | 408 ++++++- crates/editor/src/element.rs | 380 +++--- crates/editor/src/indent_guides.rs | 8 +- crates/multi_buffer/src/multi_buffer.rs | 30 + crates/outline_panel/src/outline_panel.rs | 643 ++++++++-- 9 files changed, 2397 insertions(+), 585 deletions(-) diff --git a/crates/diagnostics/src/diagnostics_tests.rs b/crates/diagnostics/src/diagnostics_tests.rs index e399fe08f31fe..ec9d86f3d526f 100644 --- a/crates/diagnostics/src/diagnostics_tests.rs +++ b/crates/diagnostics/src/diagnostics_tests.rs @@ -1050,6 +1050,7 @@ fn editor_blocks( .ok()? } + Block::FoldedBuffer { .. } => FILE_HEADER.into(), Block::ExcerptBoundary { starts_new_buffer, .. } => { diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index 9f835d5653dad..76b508079d198 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -269,7 +269,7 @@ impl DisplayMap { let start = buffer_snapshot.anchor_before(range.start); let end = buffer_snapshot.anchor_after(range.end); BlockProperties { - placement: BlockPlacement::Replace(start..end), + placement: BlockPlacement::Replace(start..=end), render, height, style, @@ -336,6 +336,38 @@ impl DisplayMap { block_map.remove_intersecting_replace_blocks(offset_ranges, inclusive); } + pub fn fold_buffer(&mut self, buffer_id: language::BufferId, cx: &mut ModelContext) { + let snapshot = self.buffer.read(cx).snapshot(cx); + let edits = self.buffer_subscription.consume().into_inner(); + let tab_size = Self::tab_size(&self.buffer, cx); + let (snapshot, edits) = self.inlay_map.sync(snapshot, edits); + let (snapshot, edits) = self.fold_map.read(snapshot, edits); + let (snapshot, edits) = self.tab_map.sync(snapshot, edits, tab_size); + let (snapshot, edits) = self + .wrap_map + .update(cx, |map, cx| map.sync(snapshot, edits, cx)); + let mut block_map = self.block_map.write(snapshot, edits); + block_map.fold_buffer(buffer_id, self.buffer.read(cx), cx) + } + + pub fn unfold_buffer(&mut self, buffer_id: language::BufferId, cx: &mut ModelContext) { + let snapshot = self.buffer.read(cx).snapshot(cx); + let edits = self.buffer_subscription.consume().into_inner(); + let tab_size = Self::tab_size(&self.buffer, cx); + let (snapshot, edits) = self.inlay_map.sync(snapshot, edits); + let (snapshot, edits) = self.fold_map.read(snapshot, edits); + let (snapshot, edits) = self.tab_map.sync(snapshot, edits, tab_size); + let (snapshot, edits) = self + .wrap_map + .update(cx, |map, cx| map.sync(snapshot, edits, cx)); + let mut block_map = self.block_map.write(snapshot, edits); + block_map.unfold_buffer(buffer_id, self.buffer.read(cx), cx) + } + + pub(crate) fn buffer_folded(&self, buffer_id: language::BufferId) -> bool { + self.block_map.folded_buffers.contains(&buffer_id) + } + pub fn insert_creases( &mut self, creases: impl IntoIterator>, @@ -712,7 +744,11 @@ impl DisplaySnapshot { } } - pub fn next_line_boundary(&self, mut point: MultiBufferPoint) -> (Point, DisplayPoint) { + pub fn next_line_boundary( + &self, + mut point: MultiBufferPoint, + ) -> (MultiBufferPoint, DisplayPoint) { + let original_point = point; loop { let mut inlay_point = self.inlay_snapshot.to_inlay_point(point); let mut fold_point = self.fold_snapshot.to_fold_point(inlay_point, Bias::Right); @@ -723,7 +759,7 @@ impl DisplaySnapshot { let mut display_point = self.point_to_display_point(point, Bias::Right); *display_point.column_mut() = self.line_len(display_point.row()); let next_point = self.display_point_to_point(display_point, Bias::Right); - if next_point == point { + if next_point == point || original_point == point || original_point == next_point { return (point, display_point); } point = next_point; @@ -1081,10 +1117,6 @@ impl DisplaySnapshot { || self.fold_snapshot.is_line_folded(buffer_row) } - pub fn is_line_replaced(&self, buffer_row: MultiBufferRow) -> bool { - self.block_snapshot.is_line_replaced(buffer_row) - } - pub fn is_block_line(&self, display_row: DisplayRow) -> bool { self.block_snapshot.is_block_line(BlockRow(display_row.0)) } @@ -2231,7 +2263,7 @@ pub mod tests { [BlockProperties { placement: BlockPlacement::Replace( buffer_snapshot.anchor_before(Point::new(1, 2)) - ..buffer_snapshot.anchor_after(Point::new(2, 3)), + ..=buffer_snapshot.anchor_after(Point::new(2, 3)), ), height: 4, style: BlockStyle::Fixed, diff --git a/crates/editor/src/display_map/block_map.rs b/crates/editor/src/display_map/block_map.rs index b495669ef8eb8..00c87c84cb2dd 100644 --- a/crates/editor/src/display_map/block_map.rs +++ b/crates/editor/src/display_map/block_map.rs @@ -4,24 +4,25 @@ use super::{ }; use crate::{EditorStyle, GutterDimensions}; use collections::{Bound, HashMap, HashSet}; -use gpui::{AnyElement, EntityId, Pixels, WindowContext}; +use gpui::{AnyElement, AppContext, EntityId, Pixels, WindowContext}; use language::{Chunk, Patch, Point}; use multi_buffer::{ - Anchor, ExcerptId, ExcerptInfo, MultiBufferRow, MultiBufferSnapshot, ToOffset, ToPoint as _, + Anchor, ExcerptId, ExcerptInfo, MultiBuffer, MultiBufferRow, MultiBufferSnapshot, ToOffset, + ToPoint as _, }; use parking_lot::Mutex; use std::{ cell::RefCell, cmp::{self, Ordering}, fmt::Debug, - ops::{Deref, DerefMut, Range, RangeBounds}, + ops::{Deref, DerefMut, Range, RangeBounds, RangeInclusive}, sync::{ atomic::{AtomicUsize, Ordering::SeqCst}, Arc, }, }; use sum_tree::{Bias, SumTree, Summary, TreeMap}; -use text::Edit; +use text::{BufferId, Edit}; use ui::ElementId; const NEWLINES: &[u8] = &[b'\n'; u8::MAX as usize]; @@ -40,6 +41,7 @@ pub struct BlockMap { buffer_header_height: u32, excerpt_header_height: u32, excerpt_footer_height: u32, + pub(super) folded_buffers: HashSet, } pub struct BlockMapReader<'a> { @@ -83,7 +85,7 @@ pub type RenderBlock = Arc AnyElement pub enum BlockPlacement { Above(T), Below(T), - Replace(Range), + Replace(RangeInclusive), } impl BlockPlacement { @@ -91,7 +93,7 @@ impl BlockPlacement { match self { BlockPlacement::Above(position) => position, BlockPlacement::Below(position) => position, - BlockPlacement::Replace(range) => &range.start, + BlockPlacement::Replace(range) => range.start(), } } @@ -99,7 +101,7 @@ impl BlockPlacement { match self { BlockPlacement::Above(position) => position, BlockPlacement::Below(position) => position, - BlockPlacement::Replace(range) => &range.end, + BlockPlacement::Replace(range) => range.end(), } } @@ -107,7 +109,7 @@ impl BlockPlacement { match self { BlockPlacement::Above(position) => BlockPlacement::Above(position), BlockPlacement::Below(position) => BlockPlacement::Below(position), - BlockPlacement::Replace(range) => BlockPlacement::Replace(&range.start..&range.end), + BlockPlacement::Replace(range) => BlockPlacement::Replace(range.start()..=range.end()), } } @@ -115,7 +117,10 @@ impl BlockPlacement { match self { BlockPlacement::Above(position) => BlockPlacement::Above(f(position)), BlockPlacement::Below(position) => BlockPlacement::Below(f(position)), - BlockPlacement::Replace(range) => BlockPlacement::Replace(f(range.start)..f(range.end)), + BlockPlacement::Replace(range) => { + let (start, end) = range.into_inner(); + BlockPlacement::Replace(f(start)..=f(end)) + } } } } @@ -134,21 +139,21 @@ impl BlockPlacement { anchor_a.cmp(anchor_b, buffer).then(Ordering::Greater) } (BlockPlacement::Above(anchor), BlockPlacement::Replace(range)) => { - anchor.cmp(&range.start, buffer).then(Ordering::Less) + anchor.cmp(range.start(), buffer).then(Ordering::Less) } (BlockPlacement::Replace(range), BlockPlacement::Above(anchor)) => { - range.start.cmp(anchor, buffer).then(Ordering::Greater) + range.start().cmp(anchor, buffer).then(Ordering::Greater) } (BlockPlacement::Below(anchor), BlockPlacement::Replace(range)) => { - anchor.cmp(&range.start, buffer).then(Ordering::Greater) + anchor.cmp(range.start(), buffer).then(Ordering::Greater) } (BlockPlacement::Replace(range), BlockPlacement::Below(anchor)) => { - range.start.cmp(anchor, buffer).then(Ordering::Less) + range.start().cmp(anchor, buffer).then(Ordering::Less) } (BlockPlacement::Replace(range_a), BlockPlacement::Replace(range_b)) => range_a - .start - .cmp(&range_b.start, buffer) - .then_with(|| range_b.end.cmp(&range_a.end, buffer)), + .start() + .cmp(range_b.start(), buffer) + .then_with(|| range_b.end().cmp(range_a.end(), buffer)), } } @@ -168,8 +173,8 @@ impl BlockPlacement { Some(BlockPlacement::Below(wrap_row)) } BlockPlacement::Replace(range) => { - let mut start = range.start.to_point(buffer_snapshot); - let mut end = range.end.to_point(buffer_snapshot); + let mut start = range.start().to_point(buffer_snapshot); + let mut end = range.end().to_point(buffer_snapshot); if start == end { None } else { @@ -179,50 +184,13 @@ impl BlockPlacement { end.column = buffer_snapshot.line_len(MultiBufferRow(end.row)); let end_wrap_row = WrapRow(wrap_snapshot.make_wrap_point(end, Bias::Left).row()); - Some(BlockPlacement::Replace(start_wrap_row..end_wrap_row)) + Some(BlockPlacement::Replace(start_wrap_row..=end_wrap_row)) } } } } } -impl Ord for BlockPlacement { - fn cmp(&self, other: &Self) -> Ordering { - match (self, other) { - (BlockPlacement::Above(row_a), BlockPlacement::Above(row_b)) - | (BlockPlacement::Below(row_a), BlockPlacement::Below(row_b)) => row_a.cmp(row_b), - (BlockPlacement::Above(row_a), BlockPlacement::Below(row_b)) => { - row_a.cmp(row_b).then(Ordering::Less) - } - (BlockPlacement::Below(row_a), BlockPlacement::Above(row_b)) => { - row_a.cmp(row_b).then(Ordering::Greater) - } - (BlockPlacement::Above(row), BlockPlacement::Replace(range)) => { - row.cmp(&range.start).then(Ordering::Less) - } - (BlockPlacement::Replace(range), BlockPlacement::Above(row)) => { - range.start.cmp(row).then(Ordering::Greater) - } - (BlockPlacement::Below(row), BlockPlacement::Replace(range)) => { - row.cmp(&range.start).then(Ordering::Greater) - } - (BlockPlacement::Replace(range), BlockPlacement::Below(row)) => { - range.start.cmp(row).then(Ordering::Less) - } - (BlockPlacement::Replace(range_a), BlockPlacement::Replace(range_b)) => range_a - .start - .cmp(&range_b.start) - .then_with(|| range_b.end.cmp(&range_a.end)), - } - } -} - -impl PartialOrd for BlockPlacement { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - pub struct CustomBlock { id: CustomBlockId, placement: BlockPlacement, @@ -272,6 +240,7 @@ pub struct BlockContext<'a, 'b> { #[derive(Debug, Clone, Copy, PartialEq, Eq, Ord, PartialOrd, Hash)] pub enum BlockId { ExcerptBoundary(Option), + FoldedBuffer(ExcerptId), Custom(CustomBlockId), } @@ -283,6 +252,7 @@ impl From for ElementId { Some(id) => ("ExcerptBoundary", EntityId::from(id)).into(), None => "LastExcerptBoundary".into(), }, + BlockId::FoldedBuffer(id) => ("FoldedBuffer", EntityId::from(id)).into(), } } } @@ -292,6 +262,7 @@ impl std::fmt::Display for BlockId { match self { Self::Custom(id) => write!(f, "Block({id:?})"), Self::ExcerptBoundary(id) => write!(f, "ExcerptHeader({id:?})"), + Self::FoldedBuffer(id) => write!(f, "FoldedBuffer({id:?})"), } } } @@ -306,6 +277,12 @@ struct Transform { #[derive(Clone)] pub enum Block { Custom(Arc), + FoldedBuffer { + first_excerpt: ExcerptInfo, + prev_excerpt: Option, + height: u32, + show_excerpt_controls: bool, + }, ExcerptBoundary { prev_excerpt: Option, next_excerpt: Option, @@ -322,26 +299,28 @@ impl Block { Block::ExcerptBoundary { next_excerpt, .. } => { BlockId::ExcerptBoundary(next_excerpt.as_ref().map(|info| info.id)) } + Block::FoldedBuffer { first_excerpt, .. } => BlockId::FoldedBuffer(first_excerpt.id), } } pub fn height(&self) -> u32 { match self { Block::Custom(block) => block.height, - Block::ExcerptBoundary { height, .. } => *height, + Block::ExcerptBoundary { height, .. } | Block::FoldedBuffer { height, .. } => *height, } } pub fn style(&self) -> BlockStyle { match self { Block::Custom(block) => block.style, - Block::ExcerptBoundary { .. } => BlockStyle::Sticky, + Block::ExcerptBoundary { .. } | Block::FoldedBuffer { .. } => BlockStyle::Sticky, } } fn place_above(&self) -> bool { match self { Block::Custom(block) => matches!(block.placement, BlockPlacement::Above(_)), + Block::FoldedBuffer { .. } => false, Block::ExcerptBoundary { next_excerpt, .. } => next_excerpt.is_some(), } } @@ -349,6 +328,7 @@ impl Block { fn place_below(&self) -> bool { match self { Block::Custom(block) => matches!(block.placement, BlockPlacement::Below(_)), + Block::FoldedBuffer { .. } => false, Block::ExcerptBoundary { next_excerpt, .. } => next_excerpt.is_none(), } } @@ -356,15 +336,36 @@ impl Block { fn is_replacement(&self) -> bool { match self { Block::Custom(block) => matches!(block.placement, BlockPlacement::Replace(_)), + Block::FoldedBuffer { .. } => true, Block::ExcerptBoundary { .. } => false, } } + + fn is_header(&self) -> bool { + match self { + Block::Custom(_) => false, + Block::FoldedBuffer { .. } => true, + Block::ExcerptBoundary { .. } => true, + } + } } impl Debug for Block { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Custom(block) => f.debug_struct("Custom").field("block", block).finish(), + Self::FoldedBuffer { + first_excerpt, + prev_excerpt, + height, + show_excerpt_controls, + } => f + .debug_struct("FoldedBuffer") + .field("first_excerpt", &first_excerpt) + .field("prev_excerpt", prev_excerpt) + .field("height", height) + .field("show_excerpt_controls", show_excerpt_controls) + .finish(), Self::ExcerptBoundary { starts_new_buffer, next_excerpt, @@ -372,9 +373,9 @@ impl Debug for Block { .. } => f .debug_struct("ExcerptBoundary") - .field("prev_excerpt", &prev_excerpt) - .field("next_excerpt", &next_excerpt) - .field("starts_new_buffer", &starts_new_buffer) + .field("prev_excerpt", prev_excerpt) + .field("next_excerpt", next_excerpt) + .field("starts_new_buffer", starts_new_buffer) .finish(), } } @@ -420,6 +421,7 @@ impl BlockMap { next_block_id: AtomicUsize::new(0), custom_blocks: Vec::new(), custom_blocks_by_id: TreeMap::default(), + folded_buffers: HashSet::default(), transforms: RefCell::new(transforms), wrap_snapshot: RefCell::new(wrap_snapshot.clone()), show_excerpt_controls, @@ -495,13 +497,20 @@ impl BlockMap { let mut old_start = WrapRow(edit.old.start); let mut new_start = WrapRow(edit.new.start); - // Preserve transforms that: - // * strictly precedes this edit - // * isomorphic or replace transforms that end *at* the start of the edit - // * below blocks that end at the start of the edit + // Only preserve transforms that: + // * Strictly precedes this edit + // * Isomorphic transforms that end *at* the start of the edit + // * Below blocks that end at the start of the edit + // However, if we hit a replace block that ends at the start of the edit we want to reconstruct it. new_transforms.append(cursor.slice(&old_start, Bias::Left, &()), &()); if let Some(transform) = cursor.item() { - if transform.summary.input_rows > 0 && cursor.end(&()) == old_start { + if transform.summary.input_rows > 0 + && cursor.end(&()) == old_start + && transform + .block + .as_ref() + .map_or(true, |b| !b.is_replacement()) + { // Preserve the transform (push and next) new_transforms.push(transform.clone(), &()); cursor.next(&()); @@ -521,7 +530,6 @@ impl BlockMap { // Ensure the edit starts at a transform boundary. // If the edit starts within an isomorphic transform, preserve its prefix // If the edit lands within a replacement block, expand the edit to include the start of the replaced input range - let mut preserved_blocks_above_edit = false; let transform = cursor.item().unwrap(); let transform_rows_before_edit = old_start.0 - cursor.start().0; if transform_rows_before_edit > 0 { @@ -539,9 +547,6 @@ impl BlockMap { debug_assert!(transform.summary.input_rows > 0); old_start.0 -= transform_rows_before_edit; new_start.0 -= transform_rows_before_edit; - // The blocks *above* it are already in the new transforms, so - // we don't need to re-insert them when querying blocks. - preserved_blocks_above_edit = true; } } @@ -643,6 +648,7 @@ impl BlockMap { self.buffer_header_height, self.excerpt_header_height, buffer, + &self.folded_buffers, (start_bound, end_bound), wrap_snapshot, )); @@ -653,12 +659,6 @@ impl BlockMap { // For each of these blocks, insert a new isomorphic transform preceding the block, // and then insert the block itself. for (block_placement, block) in blocks_in_edit.drain(..) { - if preserved_blocks_above_edit - && block_placement == BlockPlacement::Above(new_start) - { - continue; - } - let mut summary = TransformSummary { input_rows: 0, output_rows: block.height(), @@ -675,8 +675,8 @@ impl BlockMap { rows_before_block = (position.0 + 1) - new_transforms.summary().input_rows; } BlockPlacement::Replace(range) => { - rows_before_block = range.start.0 - new_transforms.summary().input_rows; - summary.input_rows = range.end.0 - range.start.0 + 1; + rows_before_block = range.start().0 - new_transforms.summary().input_rows; + summary.input_rows = range.end().0 - range.start().0 + 1; } } @@ -719,131 +719,208 @@ impl BlockMap { self.show_excerpt_controls } - fn header_and_footer_blocks<'a, 'b: 'a, 'c: 'a + 'b, R, T>( + #[allow(clippy::too_many_arguments)] + fn header_and_footer_blocks<'a, R, T>( show_excerpt_controls: bool, excerpt_footer_height: u32, buffer_header_height: u32, excerpt_header_height: u32, - buffer: &'b multi_buffer::MultiBufferSnapshot, + buffer: &'a multi_buffer::MultiBufferSnapshot, + folded_buffers: &'a HashSet, range: R, - wrap_snapshot: &'c WrapSnapshot, - ) -> impl Iterator, Block)> + 'b + wrap_snapshot: &'a WrapSnapshot, + ) -> impl Iterator, Block)> + 'a where R: RangeBounds, T: multi_buffer::ToOffset, { - buffer - .excerpt_boundaries_in_range(range) - .filter_map(move |excerpt_boundary| { - let wrap_row; - if excerpt_boundary.next.is_some() { - wrap_row = wrap_snapshot - .make_wrap_point(Point::new(excerpt_boundary.row.0, 0), Bias::Left) - .row(); - } else { - wrap_row = wrap_snapshot - .make_wrap_point( - Point::new( - excerpt_boundary.row.0, - buffer.line_len(excerpt_boundary.row), - ), - Bias::Left, - ) - .row(); - } + let mut boundaries = buffer.excerpt_boundaries_in_range(range).peekable(); - let starts_new_buffer = match (&excerpt_boundary.prev, &excerpt_boundary.next) { - (_, None) => false, - (None, Some(_)) => true, - (Some(prev), Some(next)) => prev.buffer_id != next.buffer_id, - }; + std::iter::from_fn(move || { + let excerpt_boundary = boundaries.next()?; + let wrap_row = if excerpt_boundary.next.is_some() { + wrap_snapshot.make_wrap_point(Point::new(excerpt_boundary.row.0, 0), Bias::Left) + } else { + wrap_snapshot.make_wrap_point( + Point::new( + excerpt_boundary.row.0, + buffer.line_len(excerpt_boundary.row), + ), + Bias::Left, + ) + } + .row(); - let mut height = 0; - if excerpt_boundary.prev.is_some() { - if show_excerpt_controls { - height += excerpt_footer_height; + let new_buffer_id = match (&excerpt_boundary.prev, &excerpt_boundary.next) { + (_, None) => None, + (None, Some(next)) => Some(next.buffer_id), + (Some(prev), Some(next)) => { + if prev.buffer_id != next.buffer_id { + Some(next.buffer_id) + } else { + None } } - if excerpt_boundary.next.is_some() { - if starts_new_buffer { - height += buffer_header_height; - if show_excerpt_controls { - height += excerpt_header_height; + }; + + let prev_excerpt = excerpt_boundary + .prev + .filter(|prev| !folded_buffers.contains(&prev.buffer_id)); + + let mut height = 0; + if prev_excerpt.is_some() { + if show_excerpt_controls { + height += excerpt_footer_height; + } + } + + if let Some(new_buffer_id) = new_buffer_id { + let first_excerpt = excerpt_boundary.next.clone().unwrap(); + if folded_buffers.contains(&new_buffer_id) { + let mut buffer_end = Point::new(excerpt_boundary.row.0, 0) + + excerpt_boundary.next.as_ref().unwrap().text_summary.lines; + + while let Some(next_boundary) = boundaries.peek() { + if let Some(next_excerpt_boundary) = &next_boundary.next { + if next_excerpt_boundary.buffer_id == new_buffer_id { + buffer_end = Point::new(next_boundary.row.0, 0) + + next_excerpt_boundary.text_summary.lines; + } else { + break; + } } - } else { - height += excerpt_header_height; + + boundaries.next(); } + + let wrap_end_row = wrap_snapshot.make_wrap_point(buffer_end, Bias::Right).row(); + + return Some(( + BlockPlacement::Replace(WrapRow(wrap_row)..=WrapRow(wrap_end_row)), + Block::FoldedBuffer { + prev_excerpt, + height: height + buffer_header_height, + show_excerpt_controls, + first_excerpt, + }, + )); } + } - if height == 0 { - return None; + if excerpt_boundary.next.is_some() { + if new_buffer_id.is_some() { + height += buffer_header_height; + if show_excerpt_controls { + height += excerpt_header_height; + } + } else { + height += excerpt_header_height; } + } - Some(( - if excerpt_boundary.next.is_some() { - BlockPlacement::Above(WrapRow(wrap_row)) - } else { - BlockPlacement::Below(WrapRow(wrap_row)) - }, - Block::ExcerptBoundary { - prev_excerpt: excerpt_boundary.prev, - next_excerpt: excerpt_boundary.next, - height, - starts_new_buffer, - show_excerpt_controls, - }, - )) - }) + if height == 0 { + return None; + } + + Some(( + if excerpt_boundary.next.is_some() { + BlockPlacement::Above(WrapRow(wrap_row)) + } else { + BlockPlacement::Below(WrapRow(wrap_row)) + }, + Block::ExcerptBoundary { + prev_excerpt, + next_excerpt: excerpt_boundary.next, + height, + starts_new_buffer: new_buffer_id.is_some(), + show_excerpt_controls, + }, + )) + }) } fn sort_blocks(blocks: &mut Vec<(BlockPlacement, Block)>) { blocks.sort_unstable_by(|(placement_a, block_a), (placement_b, block_b)| { - placement_a - .cmp(&placement_b) - .then_with(|| match (block_a, block_b) { - ( - Block::ExcerptBoundary { - next_excerpt: next_excerpt_a, - .. - }, - Block::ExcerptBoundary { - next_excerpt: next_excerpt_b, - .. - }, - ) => next_excerpt_a - .as_ref() - .map(|excerpt| excerpt.id) - .cmp(&next_excerpt_b.as_ref().map(|excerpt| excerpt.id)), - (Block::ExcerptBoundary { next_excerpt, .. }, Block::Custom(_)) => { - if next_excerpt.is_some() { + let placement_comparison = match (placement_a, placement_b) { + (BlockPlacement::Above(row_a), BlockPlacement::Above(row_b)) + | (BlockPlacement::Below(row_a), BlockPlacement::Below(row_b)) => row_a.cmp(row_b), + (BlockPlacement::Above(row_a), BlockPlacement::Below(row_b)) => { + row_a.cmp(row_b).then(Ordering::Less) + } + (BlockPlacement::Below(row_a), BlockPlacement::Above(row_b)) => { + row_a.cmp(row_b).then(Ordering::Greater) + } + (BlockPlacement::Above(row), BlockPlacement::Replace(range)) => { + row.cmp(range.start()).then(Ordering::Greater) + } + (BlockPlacement::Replace(range), BlockPlacement::Above(row)) => { + range.start().cmp(row).then(Ordering::Less) + } + (BlockPlacement::Below(row), BlockPlacement::Replace(range)) => { + row.cmp(range.start()).then(Ordering::Greater) + } + (BlockPlacement::Replace(range), BlockPlacement::Below(row)) => { + range.start().cmp(row).then(Ordering::Less) + } + (BlockPlacement::Replace(range_a), BlockPlacement::Replace(range_b)) => range_a + .start() + .cmp(range_b.start()) + .then_with(|| range_b.end().cmp(range_a.end())) + .then_with(|| { + if block_a.is_header() { Ordering::Less - } else { - Ordering::Greater - } - } - (Block::Custom(_), Block::ExcerptBoundary { next_excerpt, .. }) => { - if next_excerpt.is_some() { + } else if block_b.is_header() { Ordering::Greater } else { - Ordering::Less + Ordering::Equal } + }), + }; + placement_comparison.then_with(|| match (block_a, block_b) { + ( + Block::ExcerptBoundary { + next_excerpt: next_excerpt_a, + .. + }, + Block::ExcerptBoundary { + next_excerpt: next_excerpt_b, + .. + }, + ) => next_excerpt_a + .as_ref() + .map(|excerpt| excerpt.id) + .cmp(&next_excerpt_b.as_ref().map(|excerpt| excerpt.id)), + (Block::ExcerptBoundary { next_excerpt, .. }, Block::Custom(_)) => { + if next_excerpt.is_some() { + Ordering::Less + } else { + Ordering::Greater } - (Block::Custom(block_a), Block::Custom(block_b)) => block_a - .priority - .cmp(&block_b.priority) - .then_with(|| block_a.id.cmp(&block_b.id)), - }) + } + (Block::Custom(_), Block::ExcerptBoundary { next_excerpt, .. }) => { + if next_excerpt.is_some() { + Ordering::Greater + } else { + Ordering::Less + } + } + (Block::Custom(block_a), Block::Custom(block_b)) => block_a + .priority + .cmp(&block_b.priority) + .then_with(|| block_a.id.cmp(&block_b.id)), + _ => { + unreachable!() + } + }) }); - blocks.dedup_by(|(right, _), (left, _)| match (left, right) { - (BlockPlacement::Replace(range), BlockPlacement::Above(row)) => { - range.start < *row && range.end >= *row - } - (BlockPlacement::Replace(range), BlockPlacement::Below(row)) => { - range.start <= *row && range.end > *row - } + blocks.dedup_by(|right, left| match (left.0.clone(), right.0.clone()) { + (BlockPlacement::Replace(range), BlockPlacement::Above(row)) + | (BlockPlacement::Replace(range), BlockPlacement::Below(row)) => range.contains(&row), (BlockPlacement::Replace(range_a), BlockPlacement::Replace(range_b)) => { - if range_a.end >= range_b.start && range_a.start <= range_b.end { - range_a.end = range_a.end.max(range_b.end); + if range_a.end() >= range_b.start() && range_a.start() <= range_b.end() { + left.0 = BlockPlacement::Replace( + *range_a.start()..=*range_a.end().max(range_b.end()), + ); true } else { false @@ -1149,6 +1226,50 @@ impl<'a> BlockMapWriter<'a> { self.remove(blocks_to_remove); } + pub fn fold_buffer( + &mut self, + buffer_id: BufferId, + multi_buffer: &MultiBuffer, + cx: &AppContext, + ) { + self.0.folded_buffers.insert(buffer_id); + self.recompute_blocks_for_buffer(buffer_id, multi_buffer, cx); + } + + pub fn unfold_buffer( + &mut self, + buffer_id: BufferId, + multi_buffer: &MultiBuffer, + cx: &AppContext, + ) { + self.0.folded_buffers.remove(&buffer_id); + self.recompute_blocks_for_buffer(buffer_id, multi_buffer, cx); + } + + fn recompute_blocks_for_buffer( + &mut self, + buffer_id: BufferId, + multi_buffer: &MultiBuffer, + cx: &AppContext, + ) { + let wrap_snapshot = self.0.wrap_snapshot.borrow().clone(); + + let mut edits = Patch::default(); + for range in multi_buffer.excerpt_ranges_for_buffer(buffer_id, cx) { + let last_edit_row = cmp::min( + wrap_snapshot.make_wrap_point(range.end, Bias::Right).row() + 1, + wrap_snapshot.max_point().row(), + ) + 1; + let range = wrap_snapshot.make_wrap_point(range.start, Bias::Left).row()..last_edit_row; + edits.push(Edit { + old: range.clone(), + new: range, + }); + } + + self.0.sync(&wrap_snapshot, edits); + } + fn blocks_intersecting_buffer_range( &self, range: Range, @@ -1292,42 +1413,44 @@ impl BlockSnapshot { pub fn block_for_id(&self, block_id: BlockId) -> Option { let buffer = self.wrap_snapshot.buffer_snapshot(); - - match block_id { + let wrap_point = match block_id { BlockId::Custom(custom_block_id) => { let custom_block = self.custom_blocks_by_id.get(&custom_block_id)?; - Some(Block::Custom(custom_block.clone())) + return Some(Block::Custom(custom_block.clone())); } BlockId::ExcerptBoundary(next_excerpt_id) => { - let wrap_point; if let Some(next_excerpt_id) = next_excerpt_id { let excerpt_range = buffer.range_for_excerpt::(next_excerpt_id)?; - wrap_point = self - .wrap_snapshot - .make_wrap_point(excerpt_range.start, Bias::Left); + self.wrap_snapshot + .make_wrap_point(excerpt_range.start, Bias::Left) } else { - wrap_point = self - .wrap_snapshot - .make_wrap_point(buffer.max_point(), Bias::Left); + self.wrap_snapshot + .make_wrap_point(buffer.max_point(), Bias::Left) } + } + BlockId::FoldedBuffer(excerpt_id) => self.wrap_snapshot.make_wrap_point( + buffer.range_for_excerpt::(excerpt_id)?.start, + Bias::Left, + ), + }; + let wrap_row = WrapRow(wrap_point.row()); - let mut cursor = self.transforms.cursor::<(WrapRow, BlockRow)>(&()); - cursor.seek(&WrapRow(wrap_point.row()), Bias::Left, &()); - while let Some(transform) = cursor.item() { - if let Some(block) = transform.block.as_ref() { - if block.id() == block_id { - return Some(block.clone()); - } - } else if cursor.start().0 > WrapRow(wrap_point.row()) { - break; - } + let mut cursor = self.transforms.cursor::(&()); + cursor.seek(&wrap_row, Bias::Left, &()); - cursor.next(&()); + while let Some(transform) = cursor.item() { + if let Some(block) = transform.block.as_ref() { + if block.id() == block_id { + return Some(block.clone()); } - - None + } else if *cursor.start() > wrap_row { + break; } + + cursor.next(&()); } + + None } pub fn max_point(&self) -> BlockPoint { @@ -1421,11 +1544,10 @@ impl BlockSnapshot { let mut cursor = self.transforms.cursor::<(WrapRow, BlockRow)>(&()); cursor.seek(&WrapRow(wrap_point.row()), Bias::Right, &()); cursor.item().map_or(false, |transform| { - if let Some(Block::Custom(block)) = transform.block.as_ref() { - matches!(block.placement, BlockPlacement::Replace(_)) - } else { - false - } + transform + .block + .as_ref() + .map_or(false, |block| block.is_replacement()) }) } @@ -1447,13 +1569,13 @@ impl BlockSnapshot { let input_end = Point::new(input_end_row.0, 0); match transform.block.as_ref() { - Some(Block::Custom(block)) - if matches!(block.placement, BlockPlacement::Replace(_)) => - { - if ((bias == Bias::Left || search_left) && output_start <= point.0) - || (!search_left && output_start >= point.0) - { - return BlockPoint(output_start); + Some(block) => { + if block.is_replacement() { + if ((bias == Bias::Left || search_left) && output_start <= point.0) + || (!search_left && output_start >= point.0) + { + return BlockPoint(output_start); + } } } None => { @@ -1472,7 +1594,6 @@ impl BlockSnapshot { return BlockPoint(output_start + input_overshoot); } } - _ => {} } if search_left { @@ -1682,7 +1803,11 @@ impl<'a> Iterator for BlockBufferRows<'a> { let transform = self.transforms.item()?; if let Some(block) = transform.block.as_ref() { if block.is_replacement() && self.transforms.start().0 == self.output_row { - Some(self.input_buffer_rows.next().unwrap()) + if matches!(block, Block::FoldedBuffer { .. }) { + Some(None) + } else { + Some(self.input_buffer_rows.next().unwrap()) + } } else { Some(None) } @@ -1806,6 +1931,7 @@ mod tests { fold_map::FoldMap, inlay_map::InlayMap, tab_map::TabMap, wrap_map::WrapMap, }; use gpui::{div, font, px, AppContext, Context as _, Element}; + use itertools::Itertools; use language::{Buffer, Capability}; use multi_buffer::{ExcerptRange, MultiBuffer}; use rand::prelude::*; @@ -2239,16 +2365,16 @@ mod tests { let mut block_map = BlockMap::new(wraps_snapshot.clone(), false, 1, 1, 0); let mut writer = block_map.write(wraps_snapshot.clone(), Default::default()); - writer.insert(vec![BlockProperties { + let replace_block_id = writer.insert(vec![BlockProperties { style: BlockStyle::Fixed, placement: BlockPlacement::Replace( buffer_snapshot.anchor_after(Point::new(1, 3)) - ..buffer_snapshot.anchor_before(Point::new(3, 1)), + ..=buffer_snapshot.anchor_before(Point::new(3, 1)), ), height: 4, render: Arc::new(|_| div().into_any()), priority: 0, - }]); + }])[0]; let blocks_snapshot = block_map.read(wraps_snapshot, Default::default()); assert_eq!(blocks_snapshot.text(), "line1\n\n\n\n\nline5"); @@ -2273,7 +2399,7 @@ mod tests { buffer.edit( [( Point::new(1, 5)..Point::new(1, 5), - "\nline 6\nline7\nline 8\nline 9", + "\nline 2.1\nline2.2\nline 2.3\nline 2.4", )], None, cx, @@ -2292,9 +2418,16 @@ mod tests { let blocks_snapshot = block_map.read(wraps_snapshot.clone(), wrap_edits); assert_eq!(blocks_snapshot.text(), "line1\n\n\n\n\nline5"); - // Ensure blocks inserted above the start or below the end of the replaced region are shown. + // Blocks inserted right above the start or right below the end of the replaced region are hidden. let mut writer = block_map.write(wraps_snapshot.clone(), Default::default()); writer.insert(vec![ + BlockProperties { + style: BlockStyle::Fixed, + placement: BlockPlacement::Above(buffer_snapshot.anchor_after(Point::new(0, 3))), + height: 1, + render: Arc::new(|_| div().into_any()), + priority: 0, + }, BlockProperties { style: BlockStyle::Fixed, placement: BlockPlacement::Above(buffer_snapshot.anchor_after(Point::new(1, 3))), @@ -2311,7 +2444,7 @@ mod tests { }, ]); let blocks_snapshot = block_map.read(wraps_snapshot.clone(), Default::default()); - assert_eq!(blocks_snapshot.text(), "line1\n\n\n\n\n\n\nline5"); + assert_eq!(blocks_snapshot.text(), "\nline1\n\n\n\n\nline5"); // Ensure blocks inserted *inside* replaced region are hidden. let mut writer = block_map.write(wraps_snapshot.clone(), Default::default()); @@ -2338,8 +2471,470 @@ mod tests { priority: 0, }, ]); - let blocks_snapshot = block_map.read(wraps_snapshot, Default::default()); - assert_eq!(blocks_snapshot.text(), "line1\n\n\n\n\n\n\nline5"); + let blocks_snapshot = block_map.read(wraps_snapshot.clone(), Default::default()); + assert_eq!(blocks_snapshot.text(), "\nline1\n\n\n\n\nline5"); + + // Removing the replace block shows all the hidden blocks again. + let mut writer = block_map.write(wraps_snapshot.clone(), Default::default()); + writer.remove(HashSet::from_iter([replace_block_id])); + let blocks_snapshot = block_map.read(wraps_snapshot.clone(), Default::default()); + assert_eq!( + blocks_snapshot.text(), + "\nline1\n\nline2\n\n\nline 2.1\nline2.2\nline 2.3\nline 2.4\n\nline4\n\nline5" + ); + } + + #[gpui::test] + fn test_custom_blocks_inside_buffer_folds(cx: &mut gpui::TestAppContext) { + cx.update(init_test); + + let text = "111\n222\n333\n444\n555\n666"; + + let buffer = cx.update(|cx| { + MultiBuffer::build_multi( + [ + (text, vec![Point::new(0, 0)..Point::new(0, 3)]), + ( + text, + vec![ + Point::new(1, 0)..Point::new(1, 3), + Point::new(2, 0)..Point::new(2, 3), + Point::new(3, 0)..Point::new(3, 3), + ], + ), + ( + text, + vec![ + Point::new(4, 0)..Point::new(4, 3), + Point::new(5, 0)..Point::new(5, 3), + ], + ), + ], + cx, + ) + }); + let buffer_snapshot = cx.update(|cx| buffer.read(cx).snapshot(cx)); + let buffer_ids = buffer_snapshot + .excerpts() + .map(|(_, buffer_snapshot, _)| buffer_snapshot.remote_id()) + .dedup() + .collect::>(); + assert_eq!(buffer_ids.len(), 3); + let buffer_id_1 = buffer_ids[0]; + let buffer_id_2 = buffer_ids[1]; + let buffer_id_3 = buffer_ids[2]; + + let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot.clone()); + let (_, fold_snapshot) = FoldMap::new(inlay_snapshot); + let (_, tab_snapshot) = TabMap::new(fold_snapshot, 4.try_into().unwrap()); + let (_, wrap_snapshot) = + cx.update(|cx| WrapMap::new(tab_snapshot, font("Helvetica"), px(14.0), None, cx)); + let mut block_map = BlockMap::new(wrap_snapshot.clone(), true, 2, 1, 1); + let blocks_snapshot = block_map.read(wrap_snapshot.clone(), Patch::default()); + + assert_eq!( + blocks_snapshot.text(), + "\n\n\n111\n\n\n\n\n222\n\n\n333\n\n\n444\n\n\n\n\n555\n\n\n666\n" + ); + assert_eq!( + blocks_snapshot.buffer_rows(BlockRow(0)).collect::>(), + vec![ + None, + None, + None, + Some(0), + None, + None, + None, + None, + Some(1), + None, + None, + Some(2), + None, + None, + Some(3), + None, + None, + None, + None, + Some(4), + None, + None, + Some(5), + None, + ] + ); + + let mut writer = block_map.write(wrap_snapshot.clone(), Patch::default()); + let excerpt_blocks_2 = writer.insert(vec![ + BlockProperties { + style: BlockStyle::Fixed, + placement: BlockPlacement::Above(buffer_snapshot.anchor_after(Point::new(1, 0))), + height: 1, + render: Arc::new(|_| div().into_any()), + priority: 0, + }, + BlockProperties { + style: BlockStyle::Fixed, + placement: BlockPlacement::Above(buffer_snapshot.anchor_after(Point::new(2, 0))), + height: 1, + render: Arc::new(|_| div().into_any()), + priority: 0, + }, + BlockProperties { + style: BlockStyle::Fixed, + placement: BlockPlacement::Below(buffer_snapshot.anchor_after(Point::new(3, 0))), + height: 1, + render: Arc::new(|_| div().into_any()), + priority: 0, + }, + ]); + let excerpt_blocks_3 = writer.insert(vec![ + BlockProperties { + style: BlockStyle::Fixed, + placement: BlockPlacement::Above(buffer_snapshot.anchor_after(Point::new(4, 0))), + height: 1, + render: Arc::new(|_| div().into_any()), + priority: 0, + }, + BlockProperties { + style: BlockStyle::Fixed, + placement: BlockPlacement::Below(buffer_snapshot.anchor_after(Point::new(5, 0))), + height: 1, + render: Arc::new(|_| div().into_any()), + priority: 0, + }, + ]); + + let blocks_snapshot = block_map.read(wrap_snapshot.clone(), Patch::default()); + assert_eq!( + blocks_snapshot.text(), + "\n\n\n111\n\n\n\n\n\n222\n\n\n\n333\n\n\n444\n\n\n\n\n\n\n555\n\n\n666\n\n" + ); + assert_eq!( + blocks_snapshot.buffer_rows(BlockRow(0)).collect::>(), + vec![ + None, + None, + None, + Some(0), + None, + None, + None, + None, + None, + Some(1), + None, + None, + None, + Some(2), + None, + None, + Some(3), + None, + None, + None, + None, + None, + None, + Some(4), + None, + None, + Some(5), + None, + None, + ] + ); + + let mut writer = block_map.write(wrap_snapshot.clone(), Patch::default()); + buffer.read_with(cx, |buffer, cx| { + writer.fold_buffer(buffer_id_1, buffer, cx); + }); + let excerpt_blocks_1 = writer.insert(vec![BlockProperties { + style: BlockStyle::Fixed, + placement: BlockPlacement::Above(buffer_snapshot.anchor_after(Point::new(0, 0))), + height: 1, + render: Arc::new(|_| div().into_any()), + priority: 0, + }]); + let blocks_snapshot = block_map.read(wrap_snapshot.clone(), Patch::default()); + let blocks = blocks_snapshot + .blocks_in_range(0..u32::MAX) + .collect::>(); + for (_, block) in &blocks { + if let BlockId::Custom(custom_block_id) = block.id() { + assert!( + !excerpt_blocks_1.contains(&custom_block_id), + "Should have no blocks from the folded buffer" + ); + assert!( + excerpt_blocks_2.contains(&custom_block_id) + || excerpt_blocks_3.contains(&custom_block_id), + "Should have only blocks from unfolded buffers" + ); + } + } + assert_eq!( + 1, + blocks + .iter() + .filter(|(_, block)| matches!(block, Block::FoldedBuffer { .. })) + .count(), + "Should have one folded block, prodicing a header of the second buffer" + ); + assert_eq!( + blocks_snapshot.text(), + "\n\n\n\n\n\n222\n\n\n\n333\n\n\n444\n\n\n\n\n\n\n555\n\n\n666\n\n" + ); + assert_eq!( + blocks_snapshot.buffer_rows(BlockRow(0)).collect::>(), + vec![ + None, + None, + None, + None, + None, + None, + Some(1), + None, + None, + None, + Some(2), + None, + None, + Some(3), + None, + None, + None, + None, + None, + None, + Some(4), + None, + None, + Some(5), + None, + None, + ] + ); + + let mut writer = block_map.write(wrap_snapshot.clone(), Patch::default()); + buffer.read_with(cx, |buffer, cx| { + writer.fold_buffer(buffer_id_2, buffer, cx); + }); + let blocks_snapshot = block_map.read(wrap_snapshot.clone(), Patch::default()); + let blocks = blocks_snapshot + .blocks_in_range(0..u32::MAX) + .collect::>(); + for (_, block) in &blocks { + if let BlockId::Custom(custom_block_id) = block.id() { + assert!( + !excerpt_blocks_1.contains(&custom_block_id), + "Should have no blocks from the folded buffer_1" + ); + assert!( + !excerpt_blocks_2.contains(&custom_block_id), + "Should have no blocks from the folded buffer_2" + ); + assert!( + excerpt_blocks_3.contains(&custom_block_id), + "Should have only blocks from unfolded buffers" + ); + } + } + assert_eq!( + 2, + blocks + .iter() + .filter(|(_, block)| matches!(block, Block::FoldedBuffer { .. })) + .count(), + "Should have two folded blocks, producing headers" + ); + assert_eq!(blocks_snapshot.text(), "\n\n\n\n\n\n\n\n555\n\n\n666\n\n"); + assert_eq!( + blocks_snapshot.buffer_rows(BlockRow(0)).collect::>(), + vec![ + None, + None, + None, + None, + None, + None, + None, + None, + Some(4), + None, + None, + Some(5), + None, + None, + ] + ); + + let mut writer = block_map.write(wrap_snapshot.clone(), Patch::default()); + buffer.read_with(cx, |buffer, cx| { + writer.unfold_buffer(buffer_id_1, buffer, cx); + }); + let blocks_snapshot = block_map.read(wrap_snapshot.clone(), Patch::default()); + let blocks = blocks_snapshot + .blocks_in_range(0..u32::MAX) + .collect::>(); + for (_, block) in &blocks { + if let BlockId::Custom(custom_block_id) = block.id() { + assert!( + !excerpt_blocks_2.contains(&custom_block_id), + "Should have no blocks from the folded buffer_2" + ); + assert!( + excerpt_blocks_1.contains(&custom_block_id) + || excerpt_blocks_3.contains(&custom_block_id), + "Should have only blocks from unfolded buffers" + ); + } + } + assert_eq!( + 1, + blocks + .iter() + .filter(|(_, block)| matches!(block, Block::FoldedBuffer { .. })) + .count(), + "Should be back to a single folded buffer, producing a header for buffer_2" + ); + assert_eq!( + blocks_snapshot.text(), + "\n\n\n\n111\n\n\n\n\n\n\n\n555\n\n\n666\n\n", + "Should have extra newline for 111 buffer, due to a new block added when it was folded" + ); + assert_eq!( + blocks_snapshot.buffer_rows(BlockRow(0)).collect::>(), + vec![ + None, + None, + None, + None, + Some(0), + None, + None, + None, + None, + None, + None, + None, + Some(4), + None, + None, + Some(5), + None, + None, + ] + ); + + let mut writer = block_map.write(wrap_snapshot.clone(), Patch::default()); + buffer.read_with(cx, |buffer, cx| { + writer.fold_buffer(buffer_id_3, buffer, cx); + }); + let blocks_snapshot = block_map.read(wrap_snapshot.clone(), Patch::default()); + let blocks = blocks_snapshot + .blocks_in_range(0..u32::MAX) + .collect::>(); + for (_, block) in &blocks { + if let BlockId::Custom(custom_block_id) = block.id() { + assert!( + excerpt_blocks_1.contains(&custom_block_id), + "Should have no blocks from the folded buffer_1" + ); + assert!( + !excerpt_blocks_2.contains(&custom_block_id), + "Should have only blocks from unfolded buffers" + ); + assert!( + !excerpt_blocks_3.contains(&custom_block_id), + "Should have only blocks from unfolded buffers" + ); + } + } + + assert_eq!( + blocks_snapshot.text(), + "\n\n\n\n111\n\n\n\n\n", + "Should have a single, first buffer left after folding" + ); + assert_eq!( + blocks_snapshot.buffer_rows(BlockRow(0)).collect::>(), + vec![ + None, + None, + None, + None, + Some(0), + None, + None, + None, + None, + None, + ] + ); + } + + #[gpui::test] + fn test_basic_buffer_fold(cx: &mut gpui::TestAppContext) { + cx.update(init_test); + + let text = "111"; + + let buffer = cx.update(|cx| { + MultiBuffer::build_multi([(text, vec![Point::new(0, 0)..Point::new(0, 3)])], cx) + }); + let buffer_snapshot = cx.update(|cx| buffer.read(cx).snapshot(cx)); + let buffer_ids = buffer_snapshot + .excerpts() + .map(|(_, buffer_snapshot, _)| buffer_snapshot.remote_id()) + .dedup() + .collect::>(); + assert_eq!(buffer_ids.len(), 1); + let buffer_id = buffer_ids[0]; + + let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot.clone()); + let (_, fold_snapshot) = FoldMap::new(inlay_snapshot); + let (_, tab_snapshot) = TabMap::new(fold_snapshot, 4.try_into().unwrap()); + let (_, wrap_snapshot) = + cx.update(|cx| WrapMap::new(tab_snapshot, font("Helvetica"), px(14.0), None, cx)); + let mut block_map = BlockMap::new(wrap_snapshot.clone(), true, 2, 1, 1); + let blocks_snapshot = block_map.read(wrap_snapshot.clone(), Patch::default()); + + assert_eq!(blocks_snapshot.text(), "\n\n\n111\n"); + + let mut writer = block_map.write(wrap_snapshot.clone(), Patch::default()); + buffer.read_with(cx, |buffer, cx| { + writer.fold_buffer(buffer_id, buffer, cx); + }); + let blocks_snapshot = block_map.read(wrap_snapshot.clone(), Patch::default()); + let blocks = blocks_snapshot + .blocks_in_range(0..u32::MAX) + .collect::>(); + assert_eq!( + 1, + blocks + .iter() + .filter(|(_, block)| { + match block { + Block::FoldedBuffer { prev_excerpt, .. } => { + assert!(prev_excerpt.is_none()); + true + } + _ => false, + } + }) + .count(), + "Should have one folded block, prodicing a header of the second buffer" + ); + assert_eq!(blocks_snapshot.text(), "\n"); + assert_eq!( + blocks_snapshot.buffer_rows(BlockRow(0)).collect::>(), + vec![None, None], + "When fully folded, should be no buffer rows" + ); } #[gpui::test(iterations = 100)] @@ -2423,18 +3018,13 @@ mod tests { rng.gen_range(offset..=buffer.len()), Bias::Left, )); - BlockPlacement::Replace(start..end) + BlockPlacement::Replace(start..=end) } 1 => BlockPlacement::Above(buffer.anchor_after(offset)), _ => BlockPlacement::Below(buffer.anchor_after(offset)), }; let height = rng.gen_range(min_height..5); - log::info!( - "inserting block {:?} with height {}", - placement.as_ref().map(|p| p.to_point(&buffer)), - height - ); BlockProperties { style: BlockStyle::Fixed, placement, @@ -2454,13 +3044,26 @@ mod tests { wrap_map.sync(tab_snapshot, tab_edits, cx) }); let mut block_map = block_map.write(wraps_snapshot, wrap_edits); - block_map.insert(block_properties.iter().map(|props| BlockProperties { - placement: props.placement.clone(), - height: props.height, - style: props.style, - render: Arc::new(|_| div().into_any()), - priority: 0, - })); + let block_ids = + block_map.insert(block_properties.iter().map(|props| BlockProperties { + placement: props.placement.clone(), + height: props.height, + style: props.style, + render: Arc::new(|_| div().into_any()), + priority: 0, + })); + + for (block_properties, block_id) in block_properties.iter().zip(block_ids) { + log::info!( + "inserted block {:?} with height {} and id {:?}", + block_properties + .placement + .as_ref() + .map(|p| p.to_point(&buffer_snapshot)), + block_properties.height, + block_id + ); + } } 40..=59 if !block_map.custom_blocks.is_empty() => { let block_count = rng.gen_range(1..=4.min(block_map.custom_blocks.len())); @@ -2479,8 +3082,92 @@ mod tests { wrap_map.sync(tab_snapshot, tab_edits, cx) }); let mut block_map = block_map.write(wraps_snapshot, wrap_edits); + log::info!( + "removing {} blocks: {:?}", + block_ids_to_remove.len(), + block_ids_to_remove + ); block_map.remove(block_ids_to_remove); } + 60..=79 => { + if buffer.read_with(cx, |buffer, _| buffer.is_singleton()) { + log::info!("Noop fold/unfold operation on a singleton buffer"); + continue; + } + let (inlay_snapshot, inlay_edits) = + inlay_map.sync(buffer_snapshot.clone(), vec![]); + let (fold_snapshot, fold_edits) = fold_map.read(inlay_snapshot, inlay_edits); + let (tab_snapshot, tab_edits) = + tab_map.sync(fold_snapshot, fold_edits, tab_size); + let (wraps_snapshot, wrap_edits) = wrap_map.update(cx, |wrap_map, cx| { + wrap_map.sync(tab_snapshot, tab_edits, cx) + }); + let mut block_map = block_map.write(wraps_snapshot, wrap_edits); + let (unfolded_buffers, folded_buffers) = buffer.read_with(cx, |buffer, _| { + let folded_buffers = block_map + .0 + .folded_buffers + .iter() + .cloned() + .collect::>(); + let mut unfolded_buffers = buffer.excerpt_buffer_ids(); + unfolded_buffers.dedup(); + log::debug!("All buffers {unfolded_buffers:?}"); + log::debug!("Folded buffers {folded_buffers:?}"); + unfolded_buffers + .retain(|buffer_id| !block_map.0.folded_buffers.contains(buffer_id)); + (unfolded_buffers, folded_buffers) + }); + let mut folded_count = folded_buffers.len(); + let mut unfolded_count = unfolded_buffers.len(); + + let fold = !unfolded_buffers.is_empty() && rng.gen_bool(0.5); + let unfold = !folded_buffers.is_empty() && rng.gen_bool(0.5); + if !fold && !unfold { + log::info!("Noop fold/unfold operation. Unfolded buffers: {unfolded_count}, folded buffers: {folded_count}"); + continue; + } + + buffer.update(cx, |buffer, cx| { + if fold { + let buffer_to_fold = + unfolded_buffers[rng.gen_range(0..unfolded_buffers.len())]; + log::info!("Folding {buffer_to_fold:?}"); + let related_excerpts = buffer_snapshot + .excerpts() + .filter_map(|(excerpt_id, buffer, range)| { + if buffer.remote_id() == buffer_to_fold { + Some(( + excerpt_id, + buffer + .text_for_range(range.context) + .collect::(), + )) + } else { + None + } + }) + .collect::>(); + log::info!( + "Folding {buffer_to_fold:?}, related excerpts: {related_excerpts:?}" + ); + folded_count += 1; + unfolded_count -= 1; + block_map.fold_buffer(buffer_to_fold, buffer, cx); + } + if unfold { + let buffer_to_unfold = + folded_buffers[rng.gen_range(0..folded_buffers.len())]; + log::info!("Unfolding {buffer_to_unfold:?}"); + unfolded_count += 1; + folded_count -= 1; + block_map.unfold_buffer(buffer_to_unfold, buffer, cx); + } + log::info!( + "Unfolded buffers: {unfolded_count}, folded buffers: {folded_count}" + ); + }); + } _ => { buffer.update(cx, |buffer, cx| { let mutation_count = rng.gen_range(1..=5); @@ -2523,6 +3210,7 @@ mod tests { buffer_start_header_height, excerpt_header_height, &buffer_snapshot, + &block_map.folded_buffers, 0.., &wraps_snapshot, )); @@ -2590,14 +3278,19 @@ mod tests { if let Some((BlockPlacement::Replace(replace_range), block)) = sorted_blocks_iter.peek() { - if wrap_row >= replace_range.start.0 { + if wrap_row >= replace_range.start().0 { is_in_replace_block = true; - if wrap_row == replace_range.start.0 { - expected_buffer_rows.push(input_buffer_rows[multibuffer_row as usize]); + if wrap_row == replace_range.start().0 { + if matches!(block, Block::FoldedBuffer { .. }) { + expected_buffer_rows.push(None); + } else { + expected_buffer_rows + .push(input_buffer_rows[multibuffer_row as usize]); + } } - if wrap_row == replace_range.end.0 { + if wrap_row == replace_range.end().0 { expected_block_positions.push((block_row, block.id())); let text = "\n".repeat((block.height() - 1) as usize); if block_row > 0 { @@ -2654,13 +3347,18 @@ mod tests { let expected_lines = expected_text.split('\n').collect::>(); let expected_row_count = expected_lines.len(); + log::info!("expected text: {expected_text:?}"); assert_eq!( blocks_snapshot.max_point().row + 1, - expected_row_count as u32 + expected_row_count as u32, + "actual row count != expected row count", + ); + assert_eq!( + blocks_snapshot.text(), + expected_text, + "actual text != expected text", ); - - log::info!("expected text: {:?}", expected_text); for start_row in 0..expected_row_count { let end_row = rng.gen_range(start_row + 1..=expected_row_count); @@ -2851,8 +3549,7 @@ mod tests { assert_eq!( blocks_snapshot.is_line_replaced(buffer_row), expected_replaced_buffer_rows.contains(&buffer_row), - "incorrect is_line_replaced({:?})", - buffer_row + "incorrect is_line_replaced({buffer_row:?}), expected replaced rows: {expected_replaced_buffer_rows:?}", ); } } @@ -2869,7 +3566,7 @@ mod tests { fn as_custom(&self) -> Option<&CustomBlock> { match self { Block::Custom(block) => Some(block), - Block::ExcerptBoundary { .. } => None, + _ => None, } } } diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index ab8a1893f5449..d3585c24fcf3c 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -678,6 +678,7 @@ pub struct Editor { next_scroll_position: NextScrollCursorCenterTopBottom, addons: HashMap>, registered_buffers: HashMap, + toggle_fold_multiple_buffers: Task<()>, _scroll_cursor_center_top_bottom_task: Task<()>, } @@ -1325,6 +1326,7 @@ impl Editor { addons: HashMap::default(), registered_buffers: HashMap::default(), _scroll_cursor_center_top_bottom_task: Task::ready(()), + toggle_fold_multiple_buffers: Task::ready(()), text_style_refinement: None, }; this.tasks_update_task = Some(this.refresh_runnables(cx)); @@ -10311,22 +10313,53 @@ impl Editor { } pub fn toggle_fold(&mut self, _: &actions::ToggleFold, cx: &mut ViewContext) { - let selection = self.selections.newest::(cx); + if self.is_singleton(cx) { + let selection = self.selections.newest::(cx); - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let range = if selection.is_empty() { - let point = selection.head().to_display_point(&display_map); - let start = DisplayPoint::new(point.row(), 0).to_point(&display_map); - let end = DisplayPoint::new(point.row(), display_map.line_len(point.row())) - .to_point(&display_map); - start..end - } else { - selection.range() - }; - if display_map.folds_in_range(range).next().is_some() { - self.unfold_lines(&Default::default(), cx) + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let range = if selection.is_empty() { + let point = selection.head().to_display_point(&display_map); + let start = DisplayPoint::new(point.row(), 0).to_point(&display_map); + let end = DisplayPoint::new(point.row(), display_map.line_len(point.row())) + .to_point(&display_map); + start..end + } else { + selection.range() + }; + if display_map.folds_in_range(range).next().is_some() { + self.unfold_lines(&Default::default(), cx) + } else { + self.fold(&Default::default(), cx) + } } else { - self.fold(&Default::default(), cx) + let (display_snapshot, selections) = self.selections.all_adjusted_display(cx); + let mut toggled_buffers = HashSet::default(); + for selection in selections { + if let Some(buffer_id) = display_snapshot + .display_point_to_anchor(selection.head(), Bias::Right) + .buffer_id + { + if toggled_buffers.insert(buffer_id) { + if self.buffer_folded(buffer_id, cx) { + self.unfold_buffer(buffer_id, cx); + } else { + self.fold_buffer(buffer_id, cx); + } + } + } + if let Some(buffer_id) = display_snapshot + .display_point_to_anchor(selection.tail(), Bias::Left) + .buffer_id + { + if toggled_buffers.insert(buffer_id) { + if self.buffer_folded(buffer_id, cx) { + self.unfold_buffer(buffer_id, cx); + } else { + self.fold_buffer(buffer_id, cx); + } + } + } + } } } @@ -10355,44 +10388,68 @@ impl Editor { } pub fn fold(&mut self, _: &actions::Fold, cx: &mut ViewContext) { - let mut to_fold = Vec::new(); - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let selections = self.selections.all_adjusted(cx); + if self.is_singleton(cx) { + let mut to_fold = Vec::new(); + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let selections = self.selections.all_adjusted(cx); - for selection in selections { - let range = selection.range().sorted(); - let buffer_start_row = range.start.row; + for selection in selections { + let range = selection.range().sorted(); + let buffer_start_row = range.start.row; + + if range.start.row != range.end.row { + let mut found = false; + let mut row = range.start.row; + while row <= range.end.row { + if let Some(crease) = display_map.crease_for_buffer_row(MultiBufferRow(row)) + { + found = true; + row = crease.range().end.row + 1; + to_fold.push(crease); + } else { + row += 1 + } + } + if found { + continue; + } + } - if range.start.row != range.end.row { - let mut found = false; - let mut row = range.start.row; - while row <= range.end.row { + for row in (0..=range.start.row).rev() { if let Some(crease) = display_map.crease_for_buffer_row(MultiBufferRow(row)) { - found = true; - row = crease.range().end.row + 1; - to_fold.push(crease); - } else { - row += 1 + if crease.range().end.row >= buffer_start_row { + to_fold.push(crease); + if row <= range.start.row { + break; + } + } } } - if found { - continue; - } } - for row in (0..=range.start.row).rev() { - if let Some(crease) = display_map.crease_for_buffer_row(MultiBufferRow(row)) { - if crease.range().end.row >= buffer_start_row { - to_fold.push(crease); - if row <= range.start.row { - break; - } + self.fold_creases(to_fold, true, cx); + } else { + let (display_snapshot, selections) = self.selections.all_adjusted_display(cx); + let mut folded_buffers = HashSet::default(); + for selection in selections { + if let Some(buffer_id) = display_snapshot + .display_point_to_anchor(selection.head(), Bias::Right) + .buffer_id + { + if folded_buffers.insert(buffer_id) { + self.fold_buffer(buffer_id, cx); + } + } + if let Some(buffer_id) = display_snapshot + .display_point_to_anchor(selection.tail(), Bias::Left) + .buffer_id + { + if folded_buffers.insert(buffer_id) { + self.fold_buffer(buffer_id, cx); } } } } - - self.fold_creases(to_fold, true, cx); } fn fold_at_level(&mut self, fold_at: &FoldAtLevel, cx: &mut ViewContext) { @@ -10432,22 +10489,30 @@ impl Editor { } pub fn fold_all(&mut self, _: &actions::FoldAll, cx: &mut ViewContext) { - if !self.buffer.read(cx).is_singleton() { - return; - } - - let mut fold_ranges = Vec::new(); - let snapshot = self.buffer.read(cx).snapshot(cx); + if self.buffer.read(cx).is_singleton() { + let mut fold_ranges = Vec::new(); + let snapshot = self.buffer.read(cx).snapshot(cx); - for row in 0..snapshot.max_row().0 { - if let Some(foldable_range) = - self.snapshot(cx).crease_for_buffer_row(MultiBufferRow(row)) - { - fold_ranges.push(foldable_range); + for row in 0..snapshot.max_row().0 { + if let Some(foldable_range) = + self.snapshot(cx).crease_for_buffer_row(MultiBufferRow(row)) + { + fold_ranges.push(foldable_range); + } } - } - self.fold_creases(fold_ranges, true, cx); + self.fold_creases(fold_ranges, true, cx); + } else { + self.toggle_fold_multiple_buffers = cx.spawn(|editor, mut cx| async move { + editor + .update(&mut cx, |editor, cx| { + for buffer_id in editor.buffer.read(cx).excerpt_buffer_ids() { + editor.fold_buffer(buffer_id, cx); + } + }) + .ok(); + }); + } } pub fn fold_function_bodies( @@ -10519,22 +10584,45 @@ impl Editor { } pub fn unfold_lines(&mut self, _: &UnfoldLines, cx: &mut ViewContext) { - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - let buffer = &display_map.buffer_snapshot; - let selections = self.selections.all::(cx); - let ranges = selections - .iter() - .map(|s| { - let range = s.display_range(&display_map).sorted(); - let mut start = range.start.to_point(&display_map); - let mut end = range.end.to_point(&display_map); - start.column = 0; - end.column = buffer.line_len(MultiBufferRow(end.row)); - start..end - }) - .collect::>(); + if self.is_singleton(cx) { + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + let buffer = &display_map.buffer_snapshot; + let selections = self.selections.all::(cx); + let ranges = selections + .iter() + .map(|s| { + let range = s.display_range(&display_map).sorted(); + let mut start = range.start.to_point(&display_map); + let mut end = range.end.to_point(&display_map); + start.column = 0; + end.column = buffer.line_len(MultiBufferRow(end.row)); + start..end + }) + .collect::>(); - self.unfold_ranges(&ranges, true, true, cx); + self.unfold_ranges(&ranges, true, true, cx); + } else { + let (display_snapshot, selections) = self.selections.all_adjusted_display(cx); + let mut unfolded_buffers = HashSet::default(); + for selection in selections { + if let Some(buffer_id) = display_snapshot + .display_point_to_anchor(selection.head(), Bias::Right) + .buffer_id + { + if unfolded_buffers.insert(buffer_id) { + self.unfold_buffer(buffer_id, cx); + } + } + if let Some(buffer_id) = display_snapshot + .display_point_to_anchor(selection.tail(), Bias::Left) + .buffer_id + { + if unfolded_buffers.insert(buffer_id) { + self.unfold_buffer(buffer_id, cx); + } + } + } + } } pub fn unfold_recursive(&mut self, _: &UnfoldRecursive, cx: &mut ViewContext) { @@ -10574,8 +10662,20 @@ impl Editor { } pub fn unfold_all(&mut self, _: &actions::UnfoldAll, cx: &mut ViewContext) { - let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); - self.unfold_ranges(&[0..display_map.buffer_snapshot.len()], true, true, cx); + if self.buffer.read(cx).is_singleton() { + let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); + self.unfold_ranges(&[0..display_map.buffer_snapshot.len()], true, true, cx); + } else { + self.toggle_fold_multiple_buffers = cx.spawn(|editor, mut cx| async move { + editor + .update(&mut cx, |editor, cx| { + for buffer_id in editor.buffer.read(cx).excerpt_buffer_ids() { + editor.unfold_buffer(buffer_id, cx); + } + }) + .ok(); + }); + } } pub fn fold_selected_ranges(&mut self, _: &FoldSelectedRanges, cx: &mut ViewContext) { @@ -10662,6 +10762,45 @@ impl Editor { }); } + pub fn fold_buffer(&mut self, buffer_id: BufferId, cx: &mut ViewContext) { + if self.buffer().read(cx).is_singleton() || self.buffer_folded(buffer_id, cx) { + return; + } + let Some(buffer) = self.buffer().read(cx).buffer(buffer_id) else { + return; + }; + let folded_excerpts = self.buffer().read(cx).excerpts_for_buffer(&buffer, cx); + self.display_map + .update(cx, |display_map, cx| display_map.fold_buffer(buffer_id, cx)); + cx.emit(EditorEvent::BufferFoldToggled { + ids: folded_excerpts.iter().map(|&(id, _)| id).collect(), + folded: true, + }); + cx.notify(); + } + + pub fn unfold_buffer(&mut self, buffer_id: BufferId, cx: &mut ViewContext) { + if self.buffer().read(cx).is_singleton() || !self.buffer_folded(buffer_id, cx) { + return; + } + let Some(buffer) = self.buffer().read(cx).buffer(buffer_id) else { + return; + }; + let unfolded_excerpts = self.buffer().read(cx).excerpts_for_buffer(&buffer, cx); + self.display_map.update(cx, |display_map, cx| { + display_map.unfold_buffer(buffer_id, cx); + }); + cx.emit(EditorEvent::BufferFoldToggled { + ids: unfolded_excerpts.iter().map(|&(id, _)| id).collect(), + folded: false, + }); + cx.notify(); + } + + pub fn buffer_folded(&self, buffer: BufferId, cx: &AppContext) -> bool { + self.display_map.read(cx).buffer_folded(buffer) + } + /// Removes any folds with the given ranges. pub fn remove_folds_with_type( &mut self, @@ -13820,6 +13959,10 @@ pub enum EditorEvent { ExcerptsRemoved { ids: Vec, }, + BufferFoldToggled { + ids: Vec, + folded: bool, + }, ExcerptsEdited { ids: Vec, }, diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 921a4c0c28380..1436fed6bf962 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -4064,7 +4064,7 @@ async fn test_selections_and_replace_blocks(cx: &mut TestAppContext) { let snapshot = editor.snapshot(cx); let snapshot = &snapshot.buffer_snapshot; let placement = BlockPlacement::Replace( - snapshot.anchor_after(Point::new(1, 0))..snapshot.anchor_after(Point::new(3, 0)), + snapshot.anchor_after(Point::new(1, 0))..=snapshot.anchor_after(Point::new(3, 0)), ); editor.insert_blocks( [BlockProperties { @@ -13905,6 +13905,412 @@ async fn test_find_enclosing_node_with_task(cx: &mut gpui::TestAppContext) { }); } +#[gpui::test] +async fn test_multi_buffer_folding(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + + let sample_text_1 = "aaaa\nbbbb\ncccc\ndddd\neeee\nffff\ngggg\nhhhh\niiii\njjjj".to_string(); + let sample_text_2 = "llll\nmmmm\nnnnn\noooo\npppp\nqqqq\nrrrr\nssss\ntttt\nuuuu".to_string(); + let sample_text_3 = "vvvv\nwwww\nxxxx\nyyyy\nzzzz\n1111\n2222\n3333\n4444\n5555".to_string(); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/a", + json!({ + "first.rs": sample_text_1, + "second.rs": sample_text_2, + "third.rs": sample_text_3, + }), + ) + .await; + let project = Project::test(fs, ["/a".as_ref()], cx).await; + let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); + let cx = &mut VisualTestContext::from_window(*workspace.deref(), cx); + let worktree = project.update(cx, |project, cx| { + let mut worktrees = project.worktrees(cx).collect::>(); + assert_eq!(worktrees.len(), 1); + worktrees.pop().unwrap() + }); + let worktree_id = worktree.update(cx, |worktree, _| worktree.id()); + + let buffer_1 = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, "first.rs"), cx) + }) + .await + .unwrap(); + let buffer_2 = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, "second.rs"), cx) + }) + .await + .unwrap(); + let buffer_3 = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, "third.rs"), cx) + }) + .await + .unwrap(); + + let multi_buffer = cx.new_model(|cx| { + let mut multi_buffer = MultiBuffer::new(ReadWrite); + multi_buffer.push_excerpts( + buffer_1.clone(), + [ + ExcerptRange { + context: Point::new(0, 0)..Point::new(3, 0), + primary: None, + }, + ExcerptRange { + context: Point::new(5, 0)..Point::new(7, 0), + primary: None, + }, + ExcerptRange { + context: Point::new(9, 0)..Point::new(10, 4), + primary: None, + }, + ], + cx, + ); + multi_buffer.push_excerpts( + buffer_2.clone(), + [ + ExcerptRange { + context: Point::new(0, 0)..Point::new(3, 0), + primary: None, + }, + ExcerptRange { + context: Point::new(5, 0)..Point::new(7, 0), + primary: None, + }, + ExcerptRange { + context: Point::new(9, 0)..Point::new(10, 4), + primary: None, + }, + ], + cx, + ); + multi_buffer.push_excerpts( + buffer_3.clone(), + [ + ExcerptRange { + context: Point::new(0, 0)..Point::new(3, 0), + primary: None, + }, + ExcerptRange { + context: Point::new(5, 0)..Point::new(7, 0), + primary: None, + }, + ExcerptRange { + context: Point::new(9, 0)..Point::new(10, 4), + primary: None, + }, + ], + cx, + ); + multi_buffer + }); + let multi_buffer_editor = cx.new_view(|cx| { + Editor::new( + EditorMode::Full, + multi_buffer, + Some(project.clone()), + true, + cx, + ) + }); + + let full_text = "\n\n\naaaa\nbbbb\ncccc\n\n\n\nffff\ngggg\n\n\n\njjjj\n\n\n\n\nllll\nmmmm\nnnnn\n\n\n\nqqqq\nrrrr\n\n\n\nuuuu\n\n\n\n\nvvvv\nwwww\nxxxx\n\n\n\n1111\n2222\n\n\n\n5555\n"; + assert_eq!( + multi_buffer_editor.update(cx, |editor, cx| editor.display_text(cx)), + full_text, + ); + + multi_buffer_editor.update(cx, |editor, cx| { + editor.fold_buffer(buffer_1.read(cx).remote_id(), cx) + }); + assert_eq!( + multi_buffer_editor.update(cx, |editor, cx| editor.display_text(cx)), + "\n\n\n\n\nllll\nmmmm\nnnnn\n\n\n\nqqqq\nrrrr\n\n\n\nuuuu\n\n\n\n\nvvvv\nwwww\nxxxx\n\n\n\n1111\n2222\n\n\n\n5555\n", + "After folding the first buffer, its text should not be displayed" + ); + + multi_buffer_editor.update(cx, |editor, cx| { + editor.fold_buffer(buffer_2.read(cx).remote_id(), cx) + }); + assert_eq!( + multi_buffer_editor.update(cx, |editor, cx| editor.display_text(cx)), + "\n\n\n\n\n\n\nvvvv\nwwww\nxxxx\n\n\n\n1111\n2222\n\n\n\n5555\n", + "After folding the second buffer, its text should not be displayed" + ); + + multi_buffer_editor.update(cx, |editor, cx| { + editor.fold_buffer(buffer_3.read(cx).remote_id(), cx) + }); + assert_eq!( + multi_buffer_editor.update(cx, |editor, cx| editor.display_text(cx)), + "\n\n\n\n\n", + "After folding the third buffer, its text should not be displayed" + ); + + // Emulate selection inside the fold logic, that should work + multi_buffer_editor.update(cx, |editor, cx| { + editor.snapshot(cx).next_line_boundary(Point::new(0, 4)); + }); + + multi_buffer_editor.update(cx, |editor, cx| { + editor.unfold_buffer(buffer_2.read(cx).remote_id(), cx) + }); + assert_eq!( + multi_buffer_editor.update(cx, |editor, cx| editor.display_text(cx)), + "\n\n\n\n\nllll\nmmmm\nnnnn\n\n\n\nqqqq\nrrrr\n\n\n\nuuuu\n\n\n", + "After unfolding the second buffer, its text should be displayed" + ); + + multi_buffer_editor.update(cx, |editor, cx| { + editor.unfold_buffer(buffer_1.read(cx).remote_id(), cx) + }); + assert_eq!( + multi_buffer_editor.update(cx, |editor, cx| editor.display_text(cx)), + "\n\n\naaaa\nbbbb\ncccc\n\n\n\nffff\ngggg\n\n\n\njjjj\n\n\n\n\nllll\nmmmm\nnnnn\n\n\n\nqqqq\nrrrr\n\n\n\nuuuu\n\n\n", + "After unfolding the first buffer, its and 2nd buffer's text should be displayed" + ); + + multi_buffer_editor.update(cx, |editor, cx| { + editor.unfold_buffer(buffer_3.read(cx).remote_id(), cx) + }); + assert_eq!( + multi_buffer_editor.update(cx, |editor, cx| editor.display_text(cx)), + full_text, + "After unfolding the all buffers, all original text should be displayed" + ); +} + +#[gpui::test] +async fn test_multi_buffer_single_excerpts_folding(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + + let sample_text_1 = "1111\n2222\n3333".to_string(); + let sample_text_2 = "4444\n5555\n6666".to_string(); + let sample_text_3 = "7777\n8888\n9999".to_string(); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/a", + json!({ + "first.rs": sample_text_1, + "second.rs": sample_text_2, + "third.rs": sample_text_3, + }), + ) + .await; + let project = Project::test(fs, ["/a".as_ref()], cx).await; + let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); + let cx = &mut VisualTestContext::from_window(*workspace.deref(), cx); + let worktree = project.update(cx, |project, cx| { + let mut worktrees = project.worktrees(cx).collect::>(); + assert_eq!(worktrees.len(), 1); + worktrees.pop().unwrap() + }); + let worktree_id = worktree.update(cx, |worktree, _| worktree.id()); + + let buffer_1 = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, "first.rs"), cx) + }) + .await + .unwrap(); + let buffer_2 = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, "second.rs"), cx) + }) + .await + .unwrap(); + let buffer_3 = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, "third.rs"), cx) + }) + .await + .unwrap(); + + let multi_buffer = cx.new_model(|cx| { + let mut multi_buffer = MultiBuffer::new(ReadWrite); + multi_buffer.push_excerpts( + buffer_1.clone(), + [ExcerptRange { + context: Point::new(0, 0)..Point::new(3, 0), + primary: None, + }], + cx, + ); + multi_buffer.push_excerpts( + buffer_2.clone(), + [ExcerptRange { + context: Point::new(0, 0)..Point::new(3, 0), + primary: None, + }], + cx, + ); + multi_buffer.push_excerpts( + buffer_3.clone(), + [ExcerptRange { + context: Point::new(0, 0)..Point::new(3, 0), + primary: None, + }], + cx, + ); + multi_buffer + }); + + let multi_buffer_editor = cx.new_view(|cx| { + Editor::new( + EditorMode::Full, + multi_buffer, + Some(project.clone()), + true, + cx, + ) + }); + + let full_text = "\n\n\n1111\n2222\n3333\n\n\n\n\n4444\n5555\n6666\n\n\n\n\n7777\n8888\n9999\n"; + assert_eq!( + multi_buffer_editor.update(cx, |editor, cx| editor.display_text(cx)), + full_text, + ); + + multi_buffer_editor.update(cx, |editor, cx| { + editor.fold_buffer(buffer_1.read(cx).remote_id(), cx) + }); + assert_eq!( + multi_buffer_editor.update(cx, |editor, cx| editor.display_text(cx)), + "\n\n\n\n\n4444\n5555\n6666\n\n\n\n\n7777\n8888\n9999\n", + "After folding the first buffer, its text should not be displayed" + ); + + multi_buffer_editor.update(cx, |editor, cx| { + editor.fold_buffer(buffer_2.read(cx).remote_id(), cx) + }); + + assert_eq!( + multi_buffer_editor.update(cx, |editor, cx| editor.display_text(cx)), + "\n\n\n\n\n\n\n7777\n8888\n9999\n", + "After folding the second buffer, its text should not be displayed" + ); + + multi_buffer_editor.update(cx, |editor, cx| { + editor.fold_buffer(buffer_3.read(cx).remote_id(), cx) + }); + assert_eq!( + multi_buffer_editor.update(cx, |editor, cx| editor.display_text(cx)), + "\n\n\n\n\n", + "After folding the third buffer, its text should not be displayed" + ); + + multi_buffer_editor.update(cx, |editor, cx| { + editor.unfold_buffer(buffer_2.read(cx).remote_id(), cx) + }); + assert_eq!( + multi_buffer_editor.update(cx, |editor, cx| editor.display_text(cx)), + "\n\n\n\n\n4444\n5555\n6666\n\n\n", + "After unfolding the second buffer, its text should be displayed" + ); + + multi_buffer_editor.update(cx, |editor, cx| { + editor.unfold_buffer(buffer_1.read(cx).remote_id(), cx) + }); + assert_eq!( + multi_buffer_editor.update(cx, |editor, cx| editor.display_text(cx)), + "\n\n\n1111\n2222\n3333\n\n\n\n\n4444\n5555\n6666\n\n\n", + "After unfolding the first buffer, its text should be displayed" + ); + + multi_buffer_editor.update(cx, |editor, cx| { + editor.unfold_buffer(buffer_3.read(cx).remote_id(), cx) + }); + assert_eq!( + multi_buffer_editor.update(cx, |editor, cx| editor.display_text(cx)), + full_text, + "After unfolding all buffers, all original text should be displayed" + ); +} + +#[gpui::test] +async fn test_multi_buffer_with_single_excerpt_folding(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + + let sample_text = "aaaa\nbbbb\ncccc\ndddd\neeee\nffff\ngggg\nhhhh\niiii\njjjj".to_string(); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/a", + json!({ + "main.rs": sample_text, + }), + ) + .await; + let project = Project::test(fs, ["/a".as_ref()], cx).await; + let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); + let cx = &mut VisualTestContext::from_window(*workspace.deref(), cx); + let worktree = project.update(cx, |project, cx| { + let mut worktrees = project.worktrees(cx).collect::>(); + assert_eq!(worktrees.len(), 1); + worktrees.pop().unwrap() + }); + let worktree_id = worktree.update(cx, |worktree, _| worktree.id()); + + let buffer_1 = project + .update(cx, |project, cx| { + project.open_buffer((worktree_id, "main.rs"), cx) + }) + .await + .unwrap(); + + let multi_buffer = cx.new_model(|cx| { + let mut multi_buffer = MultiBuffer::new(ReadWrite); + multi_buffer.push_excerpts( + buffer_1.clone(), + [ExcerptRange { + context: Point::new(0, 0) + ..Point::new( + sample_text.chars().filter(|&c| c == '\n').count() as u32 + 1, + 0, + ), + primary: None, + }], + cx, + ); + multi_buffer + }); + let multi_buffer_editor = cx.new_view(|cx| { + Editor::new( + EditorMode::Full, + multi_buffer, + Some(project.clone()), + true, + cx, + ) + }); + + let selection_range = Point::new(1, 0)..Point::new(2, 0); + multi_buffer_editor.update(cx, |editor, cx| { + enum TestHighlight {} + let multi_buffer_snapshot = editor.buffer().read(cx).snapshot(cx); + let highlight_range = selection_range.clone().to_anchors(&multi_buffer_snapshot); + editor.highlight_text::( + vec![highlight_range.clone()], + HighlightStyle::color(Hsla::green()), + cx, + ); + editor.change_selections(None, cx, |s| s.select_ranges(Some(highlight_range))); + }); + + let full_text = format!("\n\n\n{sample_text}\n"); + assert_eq!( + multi_buffer_editor.update(cx, |editor, cx| editor.display_text(cx)), + full_text, + ); +} + fn empty_range(row: usize, column: usize) -> Range { let point = DisplayPoint::new(DisplayRow(row as u32), column as u32); point..point diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 6c049976bc408..c0431f6a06c62 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -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) { + 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::FoldedBuffer { + first_excerpt, + prev_excerpt, + show_excerpt_controls, + height, + .. + } => { + 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() + .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, - starts_new_buffer, height, + starts_new_buffer, .. } => { 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( @@ -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, 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 !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 !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, + ); + } } } } diff --git a/crates/editor/src/indent_guides.rs b/crates/editor/src/indent_guides.rs index 815825b606bf1..1e04fe4b73d43 100644 --- a/crates/editor/src/indent_guides.rs +++ b/crates/editor/src/indent_guides.rs @@ -172,13 +172,7 @@ pub fn indent_guides_in_range( let start = MultiBufferRow(indent_guide.multibuffer_row_range.start.0.saturating_sub(1)); // Filter out indent guides that are inside a fold - let is_folded = snapshot.is_line_folded(start); - let line_indent = snapshot.line_indent_for_buffer_row(start); - - let contained_in_fold = - line_indent.len(indent_guide.tab_size) <= indent_guide.indent_level(); - - !(is_folded && contained_in_fold) + !snapshot.is_line_folded(start) }) .collect() } diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index f804d4b1149b9..0e688e6cdd2bf 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -195,6 +195,7 @@ pub struct ExcerptInfo { pub buffer: BufferSnapshot, pub buffer_id: BufferId, pub range: ExcerptRange, + pub text_summary: TextSummary, } impl std::fmt::Debug for ExcerptInfo { @@ -1546,6 +1547,33 @@ impl MultiBuffer { excerpts } + pub fn excerpt_ranges_for_buffer( + &self, + buffer_id: BufferId, + cx: &AppContext, + ) -> Vec> { + let snapshot = self.read(cx); + let buffers = self.buffers.borrow(); + let mut cursor = snapshot.excerpts.cursor::<(Option<&Locator>, Point)>(&()); + buffers + .get(&buffer_id) + .into_iter() + .flat_map(|state| &state.excerpts) + .filter_map(move |locator| { + cursor.seek_forward(&Some(locator), Bias::Left, &()); + cursor.item().and_then(|excerpt| { + if excerpt.locator == *locator { + let excerpt_start = cursor.start().1; + let excerpt_end = excerpt_start + excerpt.text_summary.lines; + Some(excerpt_start..excerpt_end) + } else { + None + } + }) + }) + .collect() + } + pub fn excerpt_buffer_ids(&self) -> Vec { self.snapshot .borrow() @@ -3559,6 +3587,7 @@ impl MultiBufferSnapshot { buffer: excerpt.buffer.clone(), buffer_id: excerpt.buffer_id, range: excerpt.range.clone(), + text_summary: excerpt.text_summary.clone(), }); if next.is_none() { @@ -3574,6 +3603,7 @@ impl MultiBufferSnapshot { buffer: prev_excerpt.buffer.clone(), buffer_id: prev_excerpt.buffer_id, range: prev_excerpt.range.clone(), + text_summary: prev_excerpt.text_summary.clone(), }); let row = MultiBufferRow(cursor.start().1.row); diff --git a/crates/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs index 16c229ddd411e..24ae0ba4a2f06 100644 --- a/crates/outline_panel/src/outline_panel.rs +++ b/crates/outline_panel/src/outline_panel.rs @@ -103,6 +103,7 @@ pub struct OutlinePanel { active_item: Option, _subscriptions: Vec, updating_fs_entries: bool, + new_entries_for_fs_update: HashSet, fs_entries_update_task: Task<()>, cached_entries_update_task: Task<()>, reveal_selection_task: Task>, @@ -116,6 +117,7 @@ pub struct OutlinePanel { horizontal_scrollbar_state: ScrollbarState, hide_scrollbar_task: Option>, max_width_item_index: Option, + preserve_selection_on_buffer_fold_toggles: HashSet, } #[derive(Debug)] @@ -716,6 +718,8 @@ impl OutlinePanel { active_item: None, pending_serialization: Task::ready(None), updating_fs_entries: false, + new_entries_for_fs_update: HashSet::default(), + preserve_selection_on_buffer_fold_toggles: HashSet::default(), fs_entries_update_task: Task::ready(()), cached_entries_update_task: Task::ready(()), reveal_selection_task: Task::ready(Ok(())), @@ -811,7 +815,8 @@ impl OutlinePanel { if self.filter_editor.focus_handle(cx).is_focused(cx) { cx.propagate() } else if let Some(selected_entry) = self.selected_entry().cloned() { - self.open_entry(&selected_entry, true, false, cx); + self.toggle_expanded(&selected_entry, cx); + self.scroll_editor_to_entry(&selected_entry, true, false, cx); } } @@ -834,7 +839,7 @@ impl OutlinePanel { } else if let Some((active_editor, selected_entry)) = self.active_editor().zip(self.selected_entry().cloned()) { - self.open_entry(&selected_entry, true, true, cx); + self.scroll_editor_to_entry(&selected_entry, true, true, cx); active_editor.update(cx, |editor, cx| editor.open_excerpts(action, cx)); } } @@ -849,12 +854,12 @@ impl OutlinePanel { } else if let Some((active_editor, selected_entry)) = self.active_editor().zip(self.selected_entry().cloned()) { - self.open_entry(&selected_entry, true, true, cx); + self.scroll_editor_to_entry(&selected_entry, true, true, cx); active_editor.update(cx, |editor, cx| editor.open_excerpts_in_split(action, cx)); } } - fn open_entry( + fn scroll_editor_to_entry( &mut self, entry: &PanelEntry, prefer_selection_change: bool, @@ -866,18 +871,14 @@ impl OutlinePanel { }; let active_multi_buffer = active_editor.read(cx).buffer().clone(); let multi_buffer_snapshot = active_multi_buffer.read(cx).snapshot(cx); - let offset_from_top = if active_multi_buffer.read(cx).is_singleton() { - Point::default() - } else { - Point::new(0.0, -(active_editor.read(cx).file_header_size() as f32)) - }; - let mut change_selection = prefer_selection_change; + let mut scroll_to_buffer = None; let scroll_target = match entry { PanelEntry::FoldedDirs(..) | PanelEntry::Fs(FsEntry::Directory(..)) => None, PanelEntry::Fs(FsEntry::ExternalFile(buffer_id, _)) => { change_selection = false; - let scroll_target = multi_buffer_snapshot.excerpts().find_map( + scroll_to_buffer = Some(*buffer_id); + multi_buffer_snapshot.excerpts().find_map( |(excerpt_id, buffer_snapshot, excerpt_range)| { if &buffer_snapshot.remote_id() == buffer_id { multi_buffer_snapshot @@ -886,13 +887,12 @@ impl OutlinePanel { None } }, - ); - Some(offset_from_top).zip(scroll_target) + ) } - PanelEntry::Fs(FsEntry::File(_, file_entry, ..)) => { + PanelEntry::Fs(FsEntry::File(_, file_entry, buffer_id, _)) => { change_selection = false; - let scroll_target = self - .project + scroll_to_buffer = Some(*buffer_id); + self.project .update(cx, |project, cx| { project .path_for_entry(file_entry.id, cx) @@ -907,28 +907,23 @@ impl OutlinePanel { let (excerpt_id, excerpt_range) = excerpts.first()?; multi_buffer_snapshot .anchor_in_excerpt(*excerpt_id, excerpt_range.context.start) - }); - Some(offset_from_top).zip(scroll_target) + }) } PanelEntry::Outline(OutlineEntry::Outline(_, excerpt_id, outline)) => { - let scroll_target = multi_buffer_snapshot + multi_buffer_snapshot .anchor_in_excerpt(*excerpt_id, outline.range.start) .or_else(|| { multi_buffer_snapshot.anchor_in_excerpt(*excerpt_id, outline.range.end) - }); - Some(Point::default()).zip(scroll_target) + }) } PanelEntry::Outline(OutlineEntry::Excerpt(_, excerpt_id, excerpt_range)) => { - let scroll_target = multi_buffer_snapshot - .anchor_in_excerpt(*excerpt_id, excerpt_range.context.start); - Some(Point::default()).zip(scroll_target) - } - PanelEntry::Search(SearchEntry { match_range, .. }) => { - Some((Point::default(), match_range.start)) + change_selection = false; + multi_buffer_snapshot.anchor_in_excerpt(*excerpt_id, excerpt_range.context.start) } + PanelEntry::Search(SearchEntry { match_range, .. }) => Some(match_range.start), }; - if let Some((offset, anchor)) = scroll_target { + if let Some(anchor) = scroll_target { let activate = self .workspace .update(cx, |workspace, cx| match self.active_item() { @@ -949,6 +944,43 @@ impl OutlinePanel { ); }); } else { + let mut offset = Point::default(); + let show_excerpt_controls = active_editor + .read(cx) + .display_map + .read(cx) + .show_excerpt_controls(); + let expand_excerpt_control_height = 1.0; + if let Some(buffer_id) = scroll_to_buffer { + let current_folded = active_editor.read(cx).buffer_folded(buffer_id, cx); + if current_folded { + if show_excerpt_controls { + let previous_buffer_id = self + .fs_entries + .iter() + .rev() + .filter_map(|entry| match entry { + FsEntry::File(_, _, buffer_id, _) + | FsEntry::ExternalFile(buffer_id, _) => Some(*buffer_id), + FsEntry::Directory(..) => None, + }) + .skip_while(|id| *id != buffer_id) + .skip(1) + .next(); + if let Some(previous_buffer_id) = previous_buffer_id { + if !active_editor.read(cx).buffer_folded(previous_buffer_id, cx) + { + offset.y += expand_excerpt_control_height; + } + } + } + } else { + offset.y = -(active_editor.read(cx).file_header_size() as f32); + if show_excerpt_controls { + offset.y -= expand_excerpt_control_height; + } + } + } active_editor.update(cx, |editor, cx| { editor.set_scroll_anchor(ScrollAnchor { offset, anchor }, cx); }); @@ -977,7 +1009,7 @@ impl OutlinePanel { self.select_first(&SelectFirst {}, cx) } if let Some(selected_entry) = self.selected_entry().cloned() { - self.open_entry(&selected_entry, true, false, cx); + self.scroll_editor_to_entry(&selected_entry, true, false, cx); } } @@ -996,7 +1028,7 @@ impl OutlinePanel { self.select_last(&SelectLast, cx) } if let Some(selected_entry) = self.selected_entry().cloned() { - self.open_entry(&selected_entry, true, false, cx); + self.scroll_editor_to_entry(&selected_entry, true, false, cx); } } @@ -1230,23 +1262,34 @@ impl OutlinePanel { } fn expand_selected_entry(&mut self, _: &ExpandSelectedEntry, cx: &mut ViewContext) { - let entry_to_expand = match self.selected_entry() { - Some(PanelEntry::FoldedDirs(worktree_id, dir_entries)) => dir_entries - .last() - .map(|entry| CollapsedEntry::Dir(*worktree_id, entry.id)), - Some(PanelEntry::Fs(FsEntry::Directory(worktree_id, dir_entry))) => { + let Some(active_editor) = self.active_editor() else { + return; + }; + let Some(selected_entry) = self.selected_entry().cloned() else { + return; + }; + let mut buffers_to_unfold = HashSet::default(); + let entry_to_expand = match &selected_entry { + PanelEntry::FoldedDirs(worktree_id, dir_entries) => dir_entries.last().map(|entry| { + buffers_to_unfold.extend(self.buffers_inside_directory(*worktree_id, entry)); + CollapsedEntry::Dir(*worktree_id, entry.id) + }), + PanelEntry::Fs(FsEntry::Directory(worktree_id, dir_entry)) => { + buffers_to_unfold.extend(self.buffers_inside_directory(*worktree_id, dir_entry)); Some(CollapsedEntry::Dir(*worktree_id, dir_entry.id)) } - Some(PanelEntry::Fs(FsEntry::File(worktree_id, _, buffer_id, _))) => { + PanelEntry::Fs(FsEntry::File(worktree_id, _, buffer_id, _)) => { + buffers_to_unfold.insert(*buffer_id); Some(CollapsedEntry::File(*worktree_id, *buffer_id)) } - Some(PanelEntry::Fs(FsEntry::ExternalFile(buffer_id, _))) => { + PanelEntry::Fs(FsEntry::ExternalFile(buffer_id, _)) => { + buffers_to_unfold.insert(*buffer_id); Some(CollapsedEntry::ExternalFile(*buffer_id)) } - Some(PanelEntry::Outline(OutlineEntry::Excerpt(buffer_id, excerpt_id, _))) => { + PanelEntry::Outline(OutlineEntry::Excerpt(buffer_id, excerpt_id, _)) => { Some(CollapsedEntry::Excerpt(*buffer_id, *excerpt_id)) } - None | Some(PanelEntry::Search(_)) | Some(PanelEntry::Outline(..)) => None, + PanelEntry::Search(_) | PanelEntry::Outline(..) => return, }; let Some(collapsed_entry) = entry_to_expand else { return; @@ -1254,70 +1297,120 @@ impl OutlinePanel { let expanded = self.collapsed_entries.remove(&collapsed_entry); if expanded { if let CollapsedEntry::Dir(worktree_id, dir_entry_id) = collapsed_entry { - self.project.update(cx, |project, cx| { - project.expand_entry(worktree_id, dir_entry_id, cx); + let task = self.project.update(cx, |project, cx| { + project.expand_entry(worktree_id, dir_entry_id, cx) }); + if let Some(task) = task { + task.detach_and_log_err(cx); + } + }; + + active_editor.update(cx, |editor, cx| { + buffers_to_unfold.retain(|buffer_id| editor.buffer_folded(*buffer_id, cx)); + }); + self.select_entry(selected_entry, true, cx); + if buffers_to_unfold.is_empty() { + self.update_cached_entries(None, cx); + } else { + self.toggle_buffers_fold(buffers_to_unfold, false, cx) + .detach(); } - self.update_cached_entries(None, cx); } else { self.select_next(&SelectNext, cx) } } fn collapse_selected_entry(&mut self, _: &CollapseSelectedEntry, cx: &mut ViewContext) { + let Some(active_editor) = self.active_editor() else { + return; + }; let Some(selected_entry) = self.selected_entry().cloned() else { return; }; - match &selected_entry { + + let mut buffers_to_fold = HashSet::default(); + let collapsed = match &selected_entry { PanelEntry::Fs(FsEntry::Directory(worktree_id, selected_dir_entry)) => { - self.collapsed_entries - .insert(CollapsedEntry::Dir(*worktree_id, selected_dir_entry.id)); - self.select_entry(selected_entry, true, cx); - self.update_cached_entries(None, cx); + if self + .collapsed_entries + .insert(CollapsedEntry::Dir(*worktree_id, selected_dir_entry.id)) + { + buffers_to_fold + .extend(self.buffers_inside_directory(*worktree_id, selected_dir_entry)); + true + } else { + false + } } PanelEntry::Fs(FsEntry::File(worktree_id, _, buffer_id, _)) => { - self.collapsed_entries - .insert(CollapsedEntry::File(*worktree_id, *buffer_id)); - self.select_entry(selected_entry, true, cx); - self.update_cached_entries(None, cx); + if self + .collapsed_entries + .insert(CollapsedEntry::File(*worktree_id, *buffer_id)) + { + buffers_to_fold.insert(*buffer_id); + true + } else { + false + } } PanelEntry::Fs(FsEntry::ExternalFile(buffer_id, _)) => { - self.collapsed_entries - .insert(CollapsedEntry::ExternalFile(*buffer_id)); - self.select_entry(selected_entry, true, cx); - self.update_cached_entries(None, cx); + if self + .collapsed_entries + .insert(CollapsedEntry::ExternalFile(*buffer_id)) + { + buffers_to_fold.insert(*buffer_id); + true + } else { + false + } } PanelEntry::FoldedDirs(worktree_id, dir_entries) => { + let mut folded = false; if let Some(dir_entry) = dir_entries.last() { if self .collapsed_entries .insert(CollapsedEntry::Dir(*worktree_id, dir_entry.id)) { - self.select_entry(selected_entry, true, cx); - self.update_cached_entries(None, cx); + folded = true; + buffers_to_fold + .extend(self.buffers_inside_directory(*worktree_id, dir_entry)); } } + folded } - PanelEntry::Outline(OutlineEntry::Excerpt(buffer_id, excerpt_id, _)) => { - if self - .collapsed_entries - .insert(CollapsedEntry::Excerpt(*buffer_id, *excerpt_id)) - { - self.select_entry(selected_entry, true, cx); - self.update_cached_entries(None, cx); - } + PanelEntry::Outline(OutlineEntry::Excerpt(buffer_id, excerpt_id, _)) => self + .collapsed_entries + .insert(CollapsedEntry::Excerpt(*buffer_id, *excerpt_id)), + PanelEntry::Search(_) | PanelEntry::Outline(..) => false, + }; + + if collapsed { + active_editor.update(cx, |editor, cx| { + buffers_to_fold.retain(|buffer_id| !editor.buffer_folded(*buffer_id, cx)); + }); + self.select_entry(selected_entry, true, cx); + if buffers_to_fold.is_empty() { + self.update_cached_entries(None, cx); + } else { + self.toggle_buffers_fold(buffers_to_fold, true, cx).detach(); } - PanelEntry::Search(_) | PanelEntry::Outline(..) => {} + } else { + self.select_parent(&SelectParent, cx); } } pub fn expand_all_entries(&mut self, _: &ExpandAllEntries, cx: &mut ViewContext) { + let Some(active_editor) = self.active_editor() else { + return; + }; + let mut buffers_to_unfold = HashSet::default(); let expanded_entries = self.fs_entries .iter() .fold(HashSet::default(), |mut entries, fs_entry| { match fs_entry { FsEntry::ExternalFile(buffer_id, _) => { + buffers_to_unfold.insert(*buffer_id); entries.insert(CollapsedEntry::ExternalFile(*buffer_id)); entries.extend(self.excerpts.get(buffer_id).into_iter().flat_map( |excerpts| { @@ -1331,6 +1424,7 @@ impl OutlinePanel { entries.insert(CollapsedEntry::Dir(*worktree_id, entry.id)); } FsEntry::File(worktree_id, _, buffer_id, _) => { + buffers_to_unfold.insert(*buffer_id); entries.insert(CollapsedEntry::File(*worktree_id, *buffer_id)); entries.extend(self.excerpts.get(buffer_id).into_iter().flat_map( |excerpts| { @@ -1340,15 +1434,27 @@ impl OutlinePanel { }, )); } - } + }; entries }); self.collapsed_entries .retain(|entry| !expanded_entries.contains(entry)); - self.update_cached_entries(None, cx); + active_editor.update(cx, |editor, cx| { + buffers_to_unfold.retain(|buffer_id| editor.buffer_folded(*buffer_id, cx)); + }); + if buffers_to_unfold.is_empty() { + self.update_cached_entries(None, cx); + } else { + self.toggle_buffers_fold(buffers_to_unfold, false, cx) + .detach(); + } } pub fn collapse_all_entries(&mut self, _: &CollapseAllEntries, cx: &mut ViewContext) { + let Some(active_editor) = self.active_editor() else { + return; + }; + let mut buffers_to_fold = HashSet::default(); let new_entries = self .cached_entries .iter() @@ -1357,9 +1463,11 @@ impl OutlinePanel { Some(CollapsedEntry::Dir(*worktree_id, entry.id)) } PanelEntry::Fs(FsEntry::File(worktree_id, _, buffer_id, _)) => { + buffers_to_fold.insert(*buffer_id); Some(CollapsedEntry::File(*worktree_id, *buffer_id)) } PanelEntry::Fs(FsEntry::ExternalFile(buffer_id, _)) => { + buffers_to_fold.insert(*buffer_id); Some(CollapsedEntry::ExternalFile(*buffer_id)) } PanelEntry::FoldedDirs(worktree_id, entries) => { @@ -1372,14 +1480,28 @@ impl OutlinePanel { }) .collect::>(); self.collapsed_entries.extend(new_entries); - self.update_cached_entries(None, cx); + + active_editor.update(cx, |editor, cx| { + buffers_to_fold.retain(|buffer_id| !editor.buffer_folded(*buffer_id, cx)); + }); + if buffers_to_fold.is_empty() { + self.update_cached_entries(None, cx); + } else { + self.toggle_buffers_fold(buffers_to_fold, true, cx).detach(); + } } fn toggle_expanded(&mut self, entry: &PanelEntry, cx: &mut ViewContext) { + let Some(active_editor) = self.active_editor() else { + return; + }; + let mut fold = false; + let mut buffers_to_toggle = HashSet::default(); match entry { PanelEntry::Fs(FsEntry::Directory(worktree_id, dir_entry)) => { let entry_id = dir_entry.id; let collapsed_entry = CollapsedEntry::Dir(*worktree_id, entry_id); + buffers_to_toggle.extend(self.buffers_inside_directory(*worktree_id, dir_entry)); if self.collapsed_entries.remove(&collapsed_entry) { self.project .update(cx, |project, cx| { @@ -1389,23 +1511,31 @@ impl OutlinePanel { .detach_and_log_err(cx); } else { self.collapsed_entries.insert(collapsed_entry); + fold = true; } } PanelEntry::Fs(FsEntry::File(worktree_id, _, buffer_id, _)) => { let collapsed_entry = CollapsedEntry::File(*worktree_id, *buffer_id); + buffers_to_toggle.insert(*buffer_id); if !self.collapsed_entries.remove(&collapsed_entry) { self.collapsed_entries.insert(collapsed_entry); + fold = true; } } PanelEntry::Fs(FsEntry::ExternalFile(buffer_id, _)) => { let collapsed_entry = CollapsedEntry::ExternalFile(*buffer_id); + buffers_to_toggle.insert(*buffer_id); if !self.collapsed_entries.remove(&collapsed_entry) { self.collapsed_entries.insert(collapsed_entry); + fold = true; } } PanelEntry::FoldedDirs(worktree_id, dir_entries) => { - if let Some(entry_id) = dir_entries.first().map(|entry| entry.id) { + if let Some(dir_entry) = dir_entries.first() { + let entry_id = dir_entry.id; let collapsed_entry = CollapsedEntry::Dir(*worktree_id, entry_id); + buffers_to_toggle + .extend(self.buffers_inside_directory(*worktree_id, dir_entry)); if self.collapsed_entries.remove(&collapsed_entry) { self.project .update(cx, |project, cx| { @@ -1415,6 +1545,7 @@ impl OutlinePanel { .detach_and_log_err(cx); } else { self.collapsed_entries.insert(collapsed_entry); + fold = true; } } } @@ -1427,8 +1558,56 @@ impl OutlinePanel { PanelEntry::Search(_) | PanelEntry::Outline(..) => return, } + active_editor.update(cx, |editor, cx| { + buffers_to_toggle.retain(|buffer_id| { + let folded = editor.buffer_folded(*buffer_id, cx); + if fold { + !folded + } else { + folded + } + }); + }); + self.select_entry(entry.clone(), true, cx); - self.update_cached_entries(None, cx); + if buffers_to_toggle.is_empty() { + self.update_cached_entries(None, cx); + } else { + self.toggle_buffers_fold(buffers_to_toggle, fold, cx) + .detach(); + } + } + + fn toggle_buffers_fold( + &self, + buffers: HashSet, + fold: bool, + cx: &mut ViewContext, + ) -> Task<()> { + let Some(active_editor) = self.active_editor() else { + return Task::ready(()); + }; + cx.spawn(|outline_panel, mut cx| async move { + outline_panel + .update(&mut cx, |outline_panel, cx| { + active_editor.update(cx, |editor, cx| { + for buffer_id in buffers { + outline_panel + .preserve_selection_on_buffer_fold_toggles + .insert(buffer_id); + if fold { + editor.fold_buffer(buffer_id, cx); + } else { + editor.unfold_buffer(buffer_id, cx); + } + } + }); + if let Some(selection) = outline_panel.selected_entry().cloned() { + outline_panel.scroll_editor_to_entry(&selection, false, false, cx); + } + }) + .ok(); + }) } fn copy_path(&mut self, _: &CopyPath, cx: &mut ViewContext) { @@ -1816,7 +1995,7 @@ impl OutlinePanel { icon.unwrap_or_else(empty_icon), ) } - FsEntry::ExternalFile(buffer_id, ..) => { + FsEntry::ExternalFile(buffer_id, _) => { let color = entry_label_color(is_active); let (icon, name) = match self.buffer_snapshot_for_id(*buffer_id, cx) { Some(buffer_snapshot) => match buffer_snapshot.file() { @@ -2037,7 +2216,7 @@ impl OutlinePanel { } let change_focus = event.down.click_count > 1; outline_panel.toggle_expanded(&clicked_entry, cx); - outline_panel.open_entry(&clicked_entry, true, change_focus, cx); + outline_panel.scroll_editor_to_entry(&clicked_entry, true, change_focus, cx); }) }) .cursor_pointer() @@ -2107,8 +2286,7 @@ impl OutlinePanel { fn update_fs_entries( &mut self, - active_editor: &View, - new_entries: HashSet, + active_editor: View, debounce: Option, cx: &mut ViewContext, ) { @@ -2118,6 +2296,7 @@ impl OutlinePanel { let auto_fold_dirs = OutlinePanelSettings::get_global(cx).auto_fold_dirs; let active_multi_buffer = active_editor.read(cx).buffer().clone(); + let new_entries = self.new_entries_for_fs_update.clone(); self.updating_fs_entries = true; self.fs_entries_update_task = cx.spawn(|outline_panel, mut cx| async move { if let Some(debounce) = debounce { @@ -2141,10 +2320,11 @@ impl OutlinePanel { let worktree = file.map(|file| file.worktree.read(cx).snapshot()); let is_new = new_entries.contains(&excerpt_id) || !outline_panel.excerpts.contains_key(&buffer_id); + let is_folded = active_editor.read(cx).buffer_folded(buffer_id, cx); buffer_excerpts .entry(buffer_id) - .or_insert_with(|| (is_new, Vec::new(), entry_id, worktree)) - .1 + .or_insert_with(|| (is_new, is_folded, Vec::new(), entry_id, worktree)) + .2 .push(excerpt_id); let outlines = match outline_panel @@ -2196,8 +2376,21 @@ impl OutlinePanel { >::default(); let mut external_excerpts = HashMap::default(); - for (buffer_id, (is_new, excerpts, entry_id, worktree)) in buffer_excerpts { - if is_new { + for (buffer_id, (is_new, is_folded, excerpts, entry_id, worktree)) in + buffer_excerpts + { + if is_folded { + match &worktree { + Some(worktree) => { + new_collapsed_entries + .insert(CollapsedEntry::File(worktree.id(), buffer_id)); + } + None => { + new_collapsed_entries + .insert(CollapsedEntry::ExternalFile(buffer_id)); + } + } + } else if is_new { match &worktree { Some(worktree) => { new_collapsed_entries @@ -2438,6 +2631,7 @@ impl OutlinePanel { outline_panel .update(&mut cx, |outline_panel, cx| { outline_panel.updating_fs_entries = false; + outline_panel.new_entries_for_fs_update.clear(); outline_panel.excerpts = new_excerpts; outline_panel.collapsed_entries = new_collapsed_entries; outline_panel.unfolded_dirs = new_unfolded_dirs; @@ -2475,10 +2669,10 @@ impl OutlinePanel { item_handle: new_active_item.downgrade_item(), active_editor: new_active_editor.downgrade(), }); - let new_entries = - HashSet::from_iter(new_active_editor.read(cx).buffer().read(cx).excerpt_ids()); + self.new_entries_for_fs_update + .extend(new_active_editor.read(cx).buffer().read(cx).excerpt_ids()); self.selected_entry.invalidate(); - self.update_fs_entries(&new_active_editor, new_entries, None, cx); + self.update_fs_entries(new_active_editor, None, cx); } fn clear_previous(&mut self, cx: &mut WindowContext<'_>) { @@ -2517,6 +2711,20 @@ impl OutlinePanel { .read(cx) .excerpt_containing(selection, cx)?; let buffer_id = buffer.read(cx).remote_id(); + + if editor.read(cx).buffer_folded(buffer_id, cx) { + return self + .fs_entries + .iter() + .find(|fs_entry| match fs_entry { + FsEntry::Directory(..) => false, + FsEntry::File(_, _, file_buffer_id, _) + | FsEntry::ExternalFile(file_buffer_id, _) => *file_buffer_id == buffer_id, + }) + .cloned() + .map(PanelEntry::Fs); + } + let selection_display_point = selection.to_display_point(&editor_snapshot); match &self.mode { @@ -2919,6 +3127,9 @@ impl OutlinePanel { cx: &mut ViewContext<'_, Self>, ) -> Task<(Vec, Option)> { let project = self.project.clone(); + let Some(active_editor) = self.active_editor() else { + return Task::ready((Vec::new(), None)); + }; cx.spawn(|outline_panel, mut cx| async move { let mut generation_state = GenerationState::default(); @@ -3149,6 +3360,7 @@ impl OutlinePanel { if is_singleton || query.is_some() || (should_add && is_expanded) { outline_panel.add_search_entries( &mut generation_state, + &active_editor, entry.clone(), depth, query.clone(), @@ -3173,16 +3385,18 @@ impl OutlinePanel { None }; if let Some((buffer_id, entry_excerpts)) = excerpts_to_consider { - outline_panel.add_excerpt_entries( - &mut generation_state, - buffer_id, - entry_excerpts, - depth, - track_matches, - is_singleton, - query.as_deref(), - cx, - ); + if !active_editor.read(cx).buffer_folded(buffer_id, cx) { + outline_panel.add_excerpt_entries( + &mut generation_state, + buffer_id, + entry_excerpts, + depth, + track_matches, + is_singleton, + query.as_deref(), + cx, + ); + } } } } @@ -3536,15 +3750,13 @@ impl OutlinePanel { fn add_search_entries( &mut self, state: &mut GenerationState, + active_editor: &View, parent_entry: FsEntry, parent_depth: usize, filter_query: Option, is_singleton: bool, cx: &mut ViewContext, ) { - if self.active_editor().is_none() { - return; - }; let ItemsDisplayMode::Search(search_state) = &mut self.mode else { return; }; @@ -3560,10 +3772,27 @@ impl OutlinePanel { .collect::>(); let depth = if is_singleton { 0 } else { parent_depth + 1 }; - let new_search_matches = search_state.matches.iter().filter(|(match_range, _)| { - related_excerpts.contains(&match_range.start.excerpt_id) - || related_excerpts.contains(&match_range.end.excerpt_id) - }); + let new_search_matches = search_state + .matches + .iter() + .filter(|(match_range, _)| { + related_excerpts.contains(&match_range.start.excerpt_id) + || related_excerpts.contains(&match_range.end.excerpt_id) + }) + .filter(|(match_range, _)| { + let editor = active_editor.read(cx); + if let Some(buffer_id) = match_range.start.buffer_id { + if editor.buffer_folded(buffer_id, cx) { + return false; + } + } + if let Some(buffer_id) = match_range.start.buffer_id { + if editor.buffer_folded(buffer_id, cx) { + return false; + } + } + true + }); let new_search_entries = new_search_matches .map(|(match_range, search_data)| SearchEntry { @@ -4071,6 +4300,41 @@ impl OutlinePanel { ), ) } + + fn buffers_inside_directory( + &self, + dir_worktree: WorktreeId, + dir_entry: &Entry, + ) -> HashSet { + if !dir_entry.is_dir() { + debug_panic!("buffers_inside_directory called on a non-directory entry {dir_entry:?}"); + return HashSet::default(); + } + + self.fs_entries + .iter() + .skip_while(|fs_entry| match fs_entry { + FsEntry::Directory(worktree_id, entry) => { + *worktree_id != dir_worktree || entry != dir_entry + } + _ => true, + }) + .skip(1) + .take_while(|fs_entry| match fs_entry { + FsEntry::ExternalFile(..) => false, + FsEntry::Directory(worktree_id, entry) => { + *worktree_id == dir_worktree && entry.path.starts_with(&dir_entry.path) + } + FsEntry::File(worktree_id, entry, ..) => { + *worktree_id == dir_worktree && entry.path.starts_with(&dir_entry.path) + } + }) + .filter_map(|fs_entry| match fs_entry { + FsEntry::File(_, _, buffer_id, _) => Some(*buffer_id), + _ => None, + }) + .collect() + } } fn workspace_active_editor( @@ -4192,12 +4456,7 @@ impl Panel for OutlinePanel { if outline_panel.should_replace_active_item(active_item.as_ref()) { outline_panel.replace_active_editor(active_item, active_editor, cx); } else { - outline_panel.update_fs_entries( - &active_editor, - HashSet::default(), - None, - cx, - ) + outline_panel.update_fs_entries(active_editor, None, cx) } } else if !outline_panel.pinned { outline_panel.clear_previous(cx); @@ -4350,12 +4609,10 @@ fn subscribe_for_editor_events( cx.notify(); } EditorEvent::ExcerptsAdded { excerpts, .. } => { - outline_panel.update_fs_entries( - &editor, - excerpts.iter().map(|&(excerpt_id, _)| excerpt_id).collect(), - debounce, - cx, - ); + outline_panel + .new_entries_for_fs_update + .extend(excerpts.iter().map(|&(excerpt_id, _)| excerpt_id)); + outline_panel.update_fs_entries(editor, debounce, cx); } EditorEvent::ExcerptsRemoved { ids } => { let mut ids = ids.iter().collect::>(); @@ -4365,7 +4622,7 @@ fn subscribe_for_editor_events( break; } } - outline_panel.update_fs_entries(&editor, HashSet::default(), debounce, cx); + outline_panel.update_fs_entries(editor, debounce, cx); } EditorEvent::ExcerptsExpanded { ids } => { outline_panel.invalidate_outlines(ids); @@ -4375,6 +4632,73 @@ fn subscribe_for_editor_events( outline_panel.invalidate_outlines(ids); outline_panel.update_non_fs_items(cx); } + EditorEvent::BufferFoldToggled { ids, .. } => { + outline_panel.invalidate_outlines(ids); + let mut latest_unfolded_buffer_id = None; + let mut latest_folded_buffer_id = None; + let mut ignore_selections_change = false; + outline_panel.new_entries_for_fs_update.extend( + ids.iter() + .filter(|id| { + outline_panel + .excerpts + .iter() + .find_map(|(buffer_id, excerpts)| { + if excerpts.contains_key(id) { + ignore_selections_change |= outline_panel + .preserve_selection_on_buffer_fold_toggles + .remove(buffer_id); + Some(buffer_id) + } else { + None + } + }) + .map(|buffer_id| { + if editor.read(cx).buffer_folded(*buffer_id, cx) { + latest_folded_buffer_id = Some(*buffer_id); + false + } else { + latest_unfolded_buffer_id = Some(*buffer_id); + true + } + }) + .unwrap_or(true) + }) + .copied(), + ); + if !ignore_selections_change { + if let Some(entry_to_select) = latest_unfolded_buffer_id + .or(latest_folded_buffer_id) + .and_then(|toggled_buffer_id| { + outline_panel + .fs_entries + .iter() + .find_map(|fs_entry| match fs_entry { + FsEntry::ExternalFile(buffer_id, _) => { + if *buffer_id == toggled_buffer_id { + Some(fs_entry.clone()) + } else { + None + } + } + FsEntry::File(_, _, buffer_id, _) => { + if *buffer_id == toggled_buffer_id { + Some(fs_entry.clone()) + } else { + None + } + } + FsEntry::Directory(..) => None, + }) + }) + .map(PanelEntry::Fs) + { + outline_panel.select_entry(entry_to_select, true, cx); + } + } + + outline_panel.update_fs_entries(editor, debounce, cx); + } EditorEvent::Reparsed(buffer_id) => { if let Some(excerpts) = outline_panel.excerpts.get_mut(buffer_id) { for (_, excerpt) in excerpts { @@ -4531,6 +4855,8 @@ mod tests { outline_panel.update(cx, |outline_panel, cx| { outline_panel.collapse_selected_entry(&CollapseSelectedEntry, cx); }); + cx.executor() + .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); cx.run_until_parked(); outline_panel.update(cx, |outline_panel, cx| { assert_eq!( @@ -4563,6 +4889,8 @@ mod tests { outline_panel.update(cx, |outline_panel, cx| { outline_panel.expand_all_entries(&ExpandAllEntries, cx); }); + cx.executor() + .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); cx.run_until_parked(); outline_panel.update(cx, |outline_panel, cx| { outline_panel.select_parent(&SelectParent, cx); @@ -4591,6 +4919,8 @@ mod tests { outline_panel.update(cx, |outline_panel, cx| { outline_panel.collapse_selected_entry(&CollapseSelectedEntry, cx); }); + cx.executor() + .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); cx.run_until_parked(); outline_panel.update(cx, |outline_panel, cx| { assert_eq!( @@ -4615,6 +4945,8 @@ mod tests { outline_panel.update(cx, |outline_panel, cx| { outline_panel.expand_selected_entry(&ExpandSelectedEntry, cx); }); + cx.executor() + .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); cx.run_until_parked(); outline_panel.update(cx, |outline_panel, cx| { assert_eq!( @@ -5053,6 +5385,8 @@ mod tests { } outline_panel.collapse_selected_entry(&CollapseSelectedEntry, cx); }); + cx.executor() + .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); cx.run_until_parked(); outline_panel.update(cx, |outline_panel, cx| { assert_eq!( @@ -5072,6 +5406,91 @@ mod tests { search: static"# ); }); + + outline_panel.update(cx, |outline_panel, cx| { + // Move to the next visible non-FS entry + for _ in 0..3 { + outline_panel.select_next(&SelectNext, cx); + } + }); + cx.run_until_parked(); + outline_panel.update(cx, |outline_panel, cx| { + assert_eq!( + display_entries( + &snapshot(&outline_panel, cx), + &outline_panel.cached_entries, + outline_panel.selected_entry() + ), + r#"/ + public/lottie/ + syntax-tree.json + search: { "something": "static" } + src/ + app/(site)/ + components/ + ErrorBoundary.tsx + search: static <==== selected"# + ); + }); + + outline_panel.update(cx, |outline_panel, cx| { + outline_panel + .active_editor() + .expect("Should have an active editor") + .update(cx, |editor, cx| { + editor.toggle_fold(&editor::actions::ToggleFold, cx) + }); + }); + cx.executor() + .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); + cx.run_until_parked(); + outline_panel.update(cx, |outline_panel, cx| { + assert_eq!( + display_entries( + &snapshot(&outline_panel, cx), + &outline_panel.cached_entries, + outline_panel.selected_entry() + ), + r#"/ + public/lottie/ + syntax-tree.json + search: { "something": "static" } + src/ + app/(site)/ + components/ + ErrorBoundary.tsx <==== selected"# + ); + }); + + outline_panel.update(cx, |outline_panel, cx| { + outline_panel + .active_editor() + .expect("Should have an active editor") + .update(cx, |editor, cx| { + editor.toggle_fold(&editor::actions::ToggleFold, cx) + }); + }); + cx.executor() + .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); + cx.run_until_parked(); + outline_panel.update(cx, |outline_panel, cx| { + assert_eq!( + display_entries( + &snapshot(&outline_panel, cx), + &outline_panel.cached_entries, + outline_panel.selected_entry() + ), + r#"/ + public/lottie/ + syntax-tree.json + search: { "something": "static" } + src/ + app/(site)/ + components/ + ErrorBoundary.tsx <==== selected + search: static"# + ); + }); } async fn add_outline_panel(