zed/crates/editor/src/split.rs
Cole Miller 2a15bf630d
Require multibuffer excerpts to be ordered and nonoverlapping (#52364)
TODO:
- [x] merge main
- [x] nonshrinking `set_excerpts_for_path`
- [x] Test-drive potential problem areas in the app
- [x] prepare cloud side
- [x] test collaboration
- [ ] docstrings
- [ ] ???

## Context

### Background

Currently, a multibuffer consists of an arbitrary list of
anchor-delimited excerpts from individual buffers. Excerpt ranges for a
fixed buffer are permitted to overlap, and can appear in any order in
the multibuffer, possibly separated by excerpts from other buffers.
However, in practice all code that constructs multibuffers does so using
the APIs defined in the `path_key` submodule of the `multi_buffer` crate
(`set_excerpts_for_path` etc.) If you only use these APIs, the resulting
multibuffer will maintain the following invariants:

- All excerpts for the same buffer appear contiguously in the
multibuffer
- Excerpts for the same buffer cannot overlap
- Excerpts for the same buffer appear in order
- The placement of the excerpts for a specific buffer in the multibuffer
are determined by the `PathKey` passed to `set_excerpts_for_path`. There
is exactly one `PathKey` per buffer in the multibuffer

### Purpose of this PR

This PR changes the multibuffer so that the invariants maintained by the
`path_key` APIs *always* hold. It's no longer possible to construct a
multibuffer with overlapping excerpts, etc. The APIs that permitted
this, like `insert_excerpts_with_ids_after`, have been removed in favor
of the `path_key` suite.

The main upshot of this is that given a `text::Anchor` and a
multibuffer, it's possible to efficiently figure out the unique excerpt
that includes that anchor, if any:

```
impl MultiBufferSnapshot {
    fn buffer_anchor_to_anchor(&self, anchor: text::Anchor) -> Option<multi_buffer::Anchor>;
}
```

And in the other direction, given a `multi_buffer::Anchor`, we can look
at its `text::Anchor` to locate the excerpt that contains it. That means
we don't need an `ExcerptId` to create or resolve
`multi_buffer::Anchor`, and in fact we can delete `ExcerptId` entirely,
so that excerpts no longer have any identity outside their
`Range<text::Anchor>`.

There are a large number of changes to `editor` and other downstream
crates as a result of removing `ExcerptId` and multibuffer APIs that
assumed it.

### Other changes

There are some other improvements that are not immediate consequences of
that big change, but helped make it smoother. Notably:

- The `buffer_id` field of `text::Anchor` is no longer optional.
`text::Anchor::{MIN, MAX}` have been removed in favor of
`min_for_buffer`, etc.
- `multi_buffer::Anchor` is now a three-variant enum (inlined slightly):

```
enum Anchor {
    Min,
    Excerpt {
        text_anchor: text::Anchor,
        path_key_index: PathKeyIndex,
        diff_base_anchor: Option<text::Anchor>,
    },
    Max,
}
```

That means it's no longer possible to unconditionally access the
`text_anchor` field, which is good because most of the places that were
doing that were buggy for min/max! Instead, we have a new API that
correctly resolves min/max to the start of the first excerpt or the end
of the last excerpt:


```
impl MultiBufferSnapshot {
    fn anchor_to_buffer_anchor(&self, anchor: multi_buffer::Anchor) -> Option<text::Anchor>;
}
```
- `MultiBufferExcerpt` has been removed in favor of a new
`map_excerpt_ranges` API directly on `MultiBufferSnapshot`.

## Self-Review Checklist

<!-- Check before requesting review: -->
- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- N/A

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>
Co-authored-by: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com>
Co-authored-by: Jakub Konka <kubkon@jakubkonka.com>
Co-authored-by: Conrad <conrad@zed.dev>
2026-04-01 17:25:32 +00:00

6073 lines
183 KiB
Rust

use std::{
ops::{Range, RangeInclusive},
sync::Arc,
};
use buffer_diff::{BufferDiff, BufferDiffSnapshot};
use collections::HashMap;
use gpui::{
Action, AppContext as _, Entity, EventEmitter, Focusable, Font, Pixels, Subscription,
WeakEntity, canvas,
};
use itertools::Itertools;
use language::{Buffer, Capability, HighlightedText};
use multi_buffer::{
Anchor, AnchorRangeExt as _, BufferOffset, ExcerptRange, ExpandExcerptDirection, MultiBuffer,
MultiBufferDiffHunk, MultiBufferPoint, MultiBufferSnapshot, PathKey,
};
use project::Project;
use rope::Point;
use settings::{DiffViewStyle, Settings};
use text::{Bias, BufferId, OffsetRangeExt as _, Patch, ToPoint as _};
use ui::{
App, Context, InteractiveElement as _, IntoElement as _, ParentElement as _, Render,
Styled as _, Window, div,
};
use crate::{
display_map::CompanionExcerptPatch,
element::SplitSide,
split_editor_view::{SplitEditorState, SplitEditorView},
};
use workspace::{
ActivatePaneLeft, ActivatePaneRight, Item, ToolbarItemLocation, Workspace,
item::{ItemBufferKind, ItemEvent, SaveOptions, TabContentParams},
searchable::{SearchEvent, SearchToken, SearchableItem, SearchableItemHandle},
};
use crate::{
Autoscroll, Editor, EditorEvent, EditorSettings, RenderDiffHunkControlsFn, ToggleSoftWrap,
actions::{DisableBreakpoint, EditLogBreakpoint, EnableBreakpoint, ToggleBreakpoint},
display_map::Companion,
};
use zed_actions::assistant::InlineAssist;
pub(crate) fn convert_lhs_rows_to_rhs(
rhs_snapshot: &MultiBufferSnapshot,
lhs_snapshot: &MultiBufferSnapshot,
lhs_bounds: Range<MultiBufferPoint>,
) -> Vec<CompanionExcerptPatch> {
patches_for_range(
lhs_snapshot,
rhs_snapshot,
lhs_bounds,
|diff, range, buffer| diff.patch_for_base_text_range(range, buffer),
)
}
pub(crate) fn convert_rhs_rows_to_lhs(
lhs_snapshot: &MultiBufferSnapshot,
rhs_snapshot: &MultiBufferSnapshot,
rhs_bounds: Range<MultiBufferPoint>,
) -> Vec<CompanionExcerptPatch> {
patches_for_range(
rhs_snapshot,
lhs_snapshot,
rhs_bounds,
|diff, range, buffer| diff.patch_for_buffer_range(range, buffer),
)
}
fn rhs_range_to_base_text_range(
rhs_range: &Range<Point>,
diff_snapshot: &BufferDiffSnapshot,
rhs_buffer_snapshot: &text::BufferSnapshot,
) -> Range<Point> {
let start = diff_snapshot
.buffer_point_to_base_text_range(Point::new(rhs_range.start.row, 0), rhs_buffer_snapshot)
.start;
let end = diff_snapshot
.buffer_point_to_base_text_range(Point::new(rhs_range.end.row, 0), rhs_buffer_snapshot)
.end;
let end_column = diff_snapshot.base_text().line_len(end.row);
Point::new(start.row, 0)..Point::new(end.row, end_column)
}
fn translate_lhs_selections_to_rhs(
selections_by_buffer: &HashMap<BufferId, (Vec<Range<BufferOffset>>, Option<u32>)>,
splittable: &SplittableEditor,
cx: &App,
) -> HashMap<Entity<Buffer>, (Vec<Range<BufferOffset>>, Option<u32>)> {
let rhs_display_map = splittable.rhs_editor.read(cx).display_map.read(cx);
let Some(companion) = rhs_display_map.companion() else {
return HashMap::default();
};
let companion = companion.read(cx);
let mut translated: HashMap<Entity<Buffer>, (Vec<Range<BufferOffset>>, Option<u32>)> =
HashMap::default();
for (lhs_buffer_id, (ranges, scroll_offset)) in selections_by_buffer {
let Some(rhs_buffer_id) = companion.lhs_to_rhs_buffer(*lhs_buffer_id) else {
continue;
};
let Some(rhs_buffer) = splittable
.rhs_editor
.read(cx)
.buffer()
.read(cx)
.buffer(rhs_buffer_id)
else {
continue;
};
let Some(diff) = splittable
.rhs_editor
.read(cx)
.buffer()
.read(cx)
.diff_for(rhs_buffer_id)
else {
continue;
};
let diff_snapshot = diff.read(cx).snapshot(cx);
let rhs_buffer_snapshot = rhs_buffer.read(cx).snapshot();
let base_text_buffer = diff.read(cx).base_text_buffer();
let base_text_snapshot = base_text_buffer.read(cx).snapshot();
let translated_ranges: Vec<Range<BufferOffset>> = ranges
.iter()
.map(|range| {
let start_point = base_text_snapshot.offset_to_point(range.start.0);
let end_point = base_text_snapshot.offset_to_point(range.end.0);
let rhs_start = diff_snapshot
.base_text_point_to_buffer_point(start_point, &rhs_buffer_snapshot);
let rhs_end =
diff_snapshot.base_text_point_to_buffer_point(end_point, &rhs_buffer_snapshot);
BufferOffset(rhs_buffer_snapshot.point_to_offset(rhs_start))
..BufferOffset(rhs_buffer_snapshot.point_to_offset(rhs_end))
})
.collect();
translated.insert(rhs_buffer, (translated_ranges, *scroll_offset));
}
translated
}
fn translate_lhs_hunks_to_rhs(
lhs_hunks: &[MultiBufferDiffHunk],
splittable: &SplittableEditor,
cx: &App,
) -> Vec<MultiBufferDiffHunk> {
let rhs_display_map = splittable.rhs_editor.read(cx).display_map.read(cx);
let Some(companion) = rhs_display_map.companion() else {
return vec![];
};
let companion = companion.read(cx);
let rhs_snapshot = splittable.rhs_multibuffer.read(cx).snapshot(cx);
let rhs_hunks: Vec<MultiBufferDiffHunk> = rhs_snapshot.diff_hunks().collect();
let mut translated = Vec::new();
for lhs_hunk in lhs_hunks {
let Some(rhs_buffer_id) = companion.lhs_to_rhs_buffer(lhs_hunk.buffer_id) else {
continue;
};
if let Some(rhs_hunk) = rhs_hunks.iter().find(|rhs_hunk| {
rhs_hunk.buffer_id == rhs_buffer_id
&& rhs_hunk.diff_base_byte_range == lhs_hunk.diff_base_byte_range
}) {
translated.push(rhs_hunk.clone());
}
}
translated
}
fn patches_for_range<F>(
source_snapshot: &MultiBufferSnapshot,
target_snapshot: &MultiBufferSnapshot,
source_bounds: Range<MultiBufferPoint>,
translate_fn: F,
) -> Vec<CompanionExcerptPatch>
where
F: Fn(&BufferDiffSnapshot, RangeInclusive<Point>, &text::BufferSnapshot) -> Patch<Point>,
{
struct PendingExcerpt {
source_buffer_snapshot: language::BufferSnapshot,
source_excerpt_range: ExcerptRange<text::Anchor>,
buffer_point_range: Range<Point>,
}
let mut result = Vec::new();
let mut current_buffer_id: Option<BufferId> = None;
let mut pending_excerpts: Vec<PendingExcerpt> = Vec::new();
let mut union_context_start: Option<Point> = None;
let mut union_context_end: Option<Point> = None;
let flush_buffer = |pending: &mut Vec<PendingExcerpt>,
union_start: Point,
union_end: Point,
result: &mut Vec<CompanionExcerptPatch>| {
let Some(first) = pending.first() else {
return;
};
let diff = source_snapshot
.diff_for_buffer_id(first.source_buffer_snapshot.remote_id())
.expect("buffer with no diff when creating patches");
let source_is_lhs =
first.source_buffer_snapshot.remote_id() == diff.base_text().remote_id();
let target_buffer_id = if source_is_lhs {
diff.buffer_id()
} else {
diff.base_text().remote_id()
};
let target_buffer = target_snapshot
.buffer_for_id(target_buffer_id)
.expect("missing corresponding buffer");
let rhs_buffer = if source_is_lhs {
target_buffer
} else {
&first.source_buffer_snapshot
};
let patch = translate_fn(diff, union_start..=union_end, rhs_buffer);
for excerpt in pending.drain(..) {
let target_position = patch.old_to_new(excerpt.buffer_point_range.start);
let target_position = target_buffer.anchor_before(target_position);
let Some(target_position) = target_snapshot.anchor_in_excerpt(target_position) else {
continue;
};
let Some((target_buffer_snapshot, target_excerpt_range)) =
target_snapshot.excerpt_containing(target_position..target_position)
else {
continue;
};
result.push(patch_for_excerpt(
source_snapshot,
target_snapshot,
&excerpt.source_buffer_snapshot,
target_buffer_snapshot,
excerpt.source_excerpt_range,
target_excerpt_range,
&patch,
excerpt.buffer_point_range,
));
}
};
for (buffer_snapshot, source_range, source_excerpt_range) in
source_snapshot.range_to_buffer_ranges(source_bounds)
{
let buffer_id = buffer_snapshot.remote_id();
if current_buffer_id != Some(buffer_id) {
if let (Some(start), Some(end)) = (union_context_start.take(), union_context_end.take())
{
flush_buffer(&mut pending_excerpts, start, end, &mut result);
}
current_buffer_id = Some(buffer_id);
}
let buffer_point_range = source_range.to_point(&buffer_snapshot);
let source_context_range = source_excerpt_range.context.to_point(&buffer_snapshot);
union_context_start = Some(union_context_start.map_or(source_context_range.start, |s| {
s.min(source_context_range.start)
}));
union_context_end = Some(union_context_end.map_or(source_context_range.end, |e| {
e.max(source_context_range.end)
}));
pending_excerpts.push(PendingExcerpt {
source_buffer_snapshot: buffer_snapshot,
source_excerpt_range,
buffer_point_range,
});
}
if let (Some(start), Some(end)) = (union_context_start, union_context_end) {
flush_buffer(&mut pending_excerpts, start, end, &mut result);
}
result
}
fn patch_for_excerpt(
source_snapshot: &MultiBufferSnapshot,
target_snapshot: &MultiBufferSnapshot,
source_buffer_snapshot: &language::BufferSnapshot,
target_buffer_snapshot: &language::BufferSnapshot,
source_excerpt_range: ExcerptRange<text::Anchor>,
target_excerpt_range: ExcerptRange<text::Anchor>,
patch: &Patch<Point>,
source_edited_range: Range<Point>,
) -> CompanionExcerptPatch {
let source_buffer_range = source_excerpt_range
.context
.to_point(source_buffer_snapshot);
let source_multibuffer_range = (source_snapshot
.anchor_in_buffer(source_excerpt_range.context.start)
.expect("buffer should exist in multibuffer")
..source_snapshot
.anchor_in_buffer(source_excerpt_range.context.end)
.expect("buffer should exist in multibuffer"))
.to_point(source_snapshot);
let target_buffer_range = target_excerpt_range
.context
.to_point(target_buffer_snapshot);
let target_multibuffer_range = (target_snapshot
.anchor_in_buffer(target_excerpt_range.context.start)
.expect("buffer should exist in multibuffer")
..target_snapshot
.anchor_in_buffer(target_excerpt_range.context.end)
.expect("buffer should exist in multibuffer"))
.to_point(target_snapshot);
let edits = patch
.edits()
.iter()
.skip_while(|edit| edit.old.end < source_buffer_range.start)
.take_while(|edit| edit.old.start <= source_buffer_range.end)
.map(|edit| {
let clamped_source_start = edit.old.start.max(source_buffer_range.start);
let clamped_source_end = edit.old.end.min(source_buffer_range.end);
let source_multibuffer_start =
source_multibuffer_range.start + (clamped_source_start - source_buffer_range.start);
let source_multibuffer_end =
source_multibuffer_range.start + (clamped_source_end - source_buffer_range.start);
let clamped_target_start = edit
.new
.start
.max(target_buffer_range.start)
.min(target_buffer_range.end);
let clamped_target_end = edit
.new
.end
.max(target_buffer_range.start)
.min(target_buffer_range.end);
let target_multibuffer_start =
target_multibuffer_range.start + (clamped_target_start - target_buffer_range.start);
let target_multibuffer_end =
target_multibuffer_range.start + (clamped_target_end - target_buffer_range.start);
text::Edit {
old: source_multibuffer_start..source_multibuffer_end,
new: target_multibuffer_start..target_multibuffer_end,
}
});
let edits = [text::Edit {
old: source_multibuffer_range.start..source_multibuffer_range.start,
new: target_multibuffer_range.start..target_multibuffer_range.start,
}]
.into_iter()
.chain(edits);
let mut merged_edits: Vec<text::Edit<Point>> = Vec::new();
for edit in edits {
if let Some(last) = merged_edits.last_mut() {
if edit.new.start <= last.new.end || edit.old.start <= last.old.end {
last.old.end = last.old.end.max(edit.old.end);
last.new.end = last.new.end.max(edit.new.end);
continue;
}
}
merged_edits.push(edit);
}
let edited_range = source_multibuffer_range.start
+ (source_edited_range.start - source_buffer_range.start)
..source_multibuffer_range.start + (source_edited_range.end - source_buffer_range.start);
let source_excerpt_end =
source_multibuffer_range.start + (source_buffer_range.end - source_buffer_range.start);
let target_excerpt_end =
target_multibuffer_range.start + (target_buffer_range.end - target_buffer_range.start);
CompanionExcerptPatch {
patch: Patch::new(merged_edits),
edited_range,
source_excerpt_range: source_multibuffer_range.start..source_excerpt_end,
target_excerpt_range: target_multibuffer_range.start..target_excerpt_end,
}
}
#[derive(Clone, Copy, PartialEq, Eq, Action, Default)]
#[action(namespace = editor)]
pub struct ToggleSplitDiff;
pub struct SplittableEditor {
rhs_multibuffer: Entity<MultiBuffer>,
rhs_editor: Entity<Editor>,
lhs: Option<LhsEditor>,
workspace: WeakEntity<Workspace>,
split_state: Entity<SplitEditorState>,
searched_side: Option<SplitSide>,
/// The preferred diff style.
diff_view_style: DiffViewStyle,
/// True when the current width is below the minimum threshold for split
/// mode, regardless of the current diff view style setting.
too_narrow_for_split: bool,
last_width: Option<Pixels>,
_subscriptions: Vec<Subscription>,
}
struct LhsEditor {
multibuffer: Entity<MultiBuffer>,
editor: Entity<Editor>,
companion: Entity<Companion>,
was_last_focused: bool,
_subscriptions: Vec<Subscription>,
}
impl SplittableEditor {
pub fn rhs_editor(&self) -> &Entity<Editor> {
&self.rhs_editor
}
pub fn lhs_editor(&self) -> Option<&Entity<Editor>> {
self.lhs.as_ref().map(|s| &s.editor)
}
pub fn diff_view_style(&self) -> DiffViewStyle {
self.diff_view_style
}
pub fn is_split(&self) -> bool {
self.lhs.is_some()
}
pub fn set_render_diff_hunk_controls(
&self,
render_diff_hunk_controls: RenderDiffHunkControlsFn,
cx: &mut Context<Self>,
) {
self.rhs_editor.update(cx, |editor, cx| {
editor.set_render_diff_hunk_controls(render_diff_hunk_controls.clone(), cx);
});
if let Some(lhs) = &self.lhs {
lhs.editor.update(cx, |editor, cx| {
editor.set_render_diff_hunk_controls(render_diff_hunk_controls.clone(), cx);
});
}
}
fn focused_side(&self) -> SplitSide {
if let Some(lhs) = &self.lhs
&& lhs.was_last_focused
{
SplitSide::Left
} else {
SplitSide::Right
}
}
pub fn focused_editor(&self) -> &Entity<Editor> {
if let Some(lhs) = &self.lhs
&& lhs.was_last_focused
{
&lhs.editor
} else {
&self.rhs_editor
}
}
pub fn new(
style: DiffViewStyle,
rhs_multibuffer: Entity<MultiBuffer>,
project: Entity<Project>,
workspace: Entity<Workspace>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let rhs_editor = cx.new(|cx| {
let mut editor =
Editor::for_multibuffer(rhs_multibuffer.clone(), Some(project.clone()), window, cx);
editor.set_expand_all_diff_hunks(cx);
editor.disable_runnables();
editor.disable_diagnostics(cx);
editor.set_minimap_visibility(crate::MinimapVisibility::Disabled, window, cx);
editor
});
// TODO(split-diff) we might want to tag editor events with whether they came from rhs/lhs
let subscriptions = vec![
cx.subscribe(
&rhs_editor,
|this, _, event: &EditorEvent, cx| match event {
EditorEvent::ExpandExcerptsRequested {
excerpt_anchors,
lines,
direction,
} => {
this.expand_excerpts(
excerpt_anchors.iter().copied(),
*lines,
*direction,
cx,
);
}
_ => cx.emit(event.clone()),
},
),
cx.subscribe(&rhs_editor, |this, _, event: &SearchEvent, cx| {
if this.searched_side.is_none() || this.searched_side == Some(SplitSide::Right) {
cx.emit(event.clone());
}
}),
];
let this = cx.weak_entity();
window.defer(cx, {
let workspace = workspace.downgrade();
let rhs_editor = rhs_editor.downgrade();
move |window, cx| {
workspace
.update(cx, |workspace, cx| {
rhs_editor
.update(cx, |editor, cx| {
editor.added_to_workspace(workspace, window, cx);
})
.ok();
})
.ok();
if style == DiffViewStyle::Split {
this.update(cx, |this, cx| {
this.split(window, cx);
})
.ok();
}
}
});
let split_state = cx.new(|cx| SplitEditorState::new(cx));
Self {
diff_view_style: style,
rhs_editor,
rhs_multibuffer,
lhs: None,
workspace: workspace.downgrade(),
split_state,
searched_side: None,
too_narrow_for_split: false,
last_width: None,
_subscriptions: subscriptions,
}
}
pub fn split(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.lhs.is_some() {
return;
}
let Some(workspace) = self.workspace.upgrade() else {
return;
};
let project = workspace.read(cx).project().clone();
let lhs_multibuffer = cx.new(|cx| {
let mut multibuffer = MultiBuffer::new(Capability::ReadOnly);
multibuffer.set_all_diff_hunks_expanded(cx);
multibuffer
});
let render_diff_hunk_controls = self.rhs_editor.read(cx).render_diff_hunk_controls.clone();
let lhs_editor = cx.new(|cx| {
let mut editor =
Editor::for_multibuffer(lhs_multibuffer.clone(), Some(project.clone()), window, cx);
editor.set_number_deleted_lines(true, cx);
editor.set_delegate_expand_excerpts(true);
editor.set_delegate_stage_and_restore(true);
editor.set_delegate_open_excerpts(true);
editor.set_show_vertical_scrollbar(false, cx);
editor.disable_lsp_data();
editor.disable_runnables();
editor.disable_diagnostics(cx);
editor.set_minimap_visibility(crate::MinimapVisibility::Disabled, window, cx);
editor
});
lhs_editor.update(cx, |editor, cx| {
editor.set_render_diff_hunk_controls(render_diff_hunk_controls, cx);
});
let mut subscriptions = vec![cx.subscribe_in(
&lhs_editor,
window,
|this, _, event: &EditorEvent, window, cx| match event {
EditorEvent::ExpandExcerptsRequested {
excerpt_anchors,
lines,
direction,
} => {
if let Some(lhs) = &this.lhs {
let rhs_snapshot = this.rhs_multibuffer.read(cx).snapshot(cx);
let lhs_snapshot = lhs.multibuffer.read(cx).snapshot(cx);
let rhs_anchors = excerpt_anchors
.iter()
.filter_map(|anchor| {
let (anchor, lhs_buffer) =
lhs_snapshot.anchor_to_buffer_anchor(*anchor)?;
let rhs_buffer_id =
lhs.companion.read(cx).lhs_to_rhs_buffer(anchor.buffer_id)?;
let rhs_buffer = rhs_snapshot.buffer_for_id(rhs_buffer_id)?;
let diff = this.rhs_multibuffer.read(cx).diff_for(rhs_buffer_id)?;
let diff_snapshot = diff.read(cx).snapshot(cx);
let rhs_point = diff_snapshot.base_text_point_to_buffer_point(
anchor.to_point(&lhs_buffer),
&rhs_buffer,
);
rhs_snapshot.anchor_in_excerpt(rhs_buffer.anchor_before(rhs_point))
})
.collect::<Vec<_>>();
this.expand_excerpts(rhs_anchors.into_iter(), *lines, *direction, cx);
}
}
EditorEvent::StageOrUnstageRequested { stage, hunks } => {
if this.lhs.is_some() {
let translated = translate_lhs_hunks_to_rhs(hunks, this, cx);
if !translated.is_empty() {
let stage = *stage;
this.rhs_editor.update(cx, |editor, cx| {
let chunk_by = translated.into_iter().chunk_by(|h| h.buffer_id);
for (buffer_id, hunks) in &chunk_by {
editor.do_stage_or_unstage(stage, buffer_id, hunks, cx);
}
});
}
}
}
EditorEvent::RestoreRequested { hunks } => {
if this.lhs.is_some() {
let translated = translate_lhs_hunks_to_rhs(hunks, this, cx);
if !translated.is_empty() {
this.rhs_editor.update(cx, |editor, cx| {
editor.restore_diff_hunks(translated, cx);
});
}
}
}
EditorEvent::OpenExcerptsRequested {
selections_by_buffer,
split,
} => {
if this.lhs.is_some() {
let translated =
translate_lhs_selections_to_rhs(selections_by_buffer, this, cx);
if !translated.is_empty() {
let workspace = this.workspace.clone();
let split = *split;
Editor::open_buffers_in_workspace(
workspace, translated, split, window, cx,
);
}
}
}
_ => cx.emit(event.clone()),
},
)];
subscriptions.push(
cx.subscribe(&lhs_editor, |this, _, event: &SearchEvent, cx| {
if this.searched_side == Some(SplitSide::Left) {
cx.emit(event.clone());
}
}),
);
let lhs_focus_handle = lhs_editor.read(cx).focus_handle(cx);
subscriptions.push(
cx.on_focus_in(&lhs_focus_handle, window, |this, _window, cx| {
if let Some(lhs) = &mut this.lhs {
if !lhs.was_last_focused {
lhs.was_last_focused = true;
cx.notify();
}
}
}),
);
let rhs_focus_handle = self.rhs_editor.read(cx).focus_handle(cx);
subscriptions.push(
cx.on_focus_in(&rhs_focus_handle, window, |this, _window, cx| {
if let Some(lhs) = &mut this.lhs {
if lhs.was_last_focused {
lhs.was_last_focused = false;
cx.notify();
}
}
}),
);
let rhs_display_map = self.rhs_editor.read(cx).display_map.clone();
let lhs_display_map = lhs_editor.read(cx).display_map.clone();
let rhs_display_map_id = rhs_display_map.entity_id();
let companion = cx.new(|_| {
Companion::new(
rhs_display_map_id,
convert_rhs_rows_to_lhs,
convert_lhs_rows_to_rhs,
)
});
let lhs = LhsEditor {
editor: lhs_editor,
multibuffer: lhs_multibuffer,
was_last_focused: false,
companion: companion.clone(),
_subscriptions: subscriptions,
};
self.rhs_editor.update(cx, |editor, cx| {
editor.set_delegate_expand_excerpts(true);
editor.buffer().update(cx, |rhs_multibuffer, cx| {
rhs_multibuffer.set_show_deleted_hunks(false, cx);
rhs_multibuffer.set_use_extended_diff_range(true, cx);
})
});
let all_paths: Vec<_> = {
let rhs_multibuffer = self.rhs_multibuffer.read(cx);
let rhs_multibuffer_snapshot = rhs_multibuffer.snapshot(cx);
rhs_multibuffer_snapshot
.buffers_with_paths()
.filter_map(|(buffer, path)| {
let diff = rhs_multibuffer.diff_for(buffer.remote_id())?;
Some((path.clone(), diff))
})
.collect()
};
self.lhs = Some(lhs);
self.sync_lhs_for_paths(all_paths, &companion, cx);
rhs_display_map.update(cx, |dm, cx| {
dm.set_companion(Some((lhs_display_map, companion.clone())), cx);
});
let lhs = self.lhs.as_ref().unwrap();
let shared_scroll_anchor = self
.rhs_editor
.read(cx)
.scroll_manager
.scroll_anchor_entity();
lhs.editor.update(cx, |editor, _cx| {
editor
.scroll_manager
.set_shared_scroll_anchor(shared_scroll_anchor);
});
let this = cx.entity().downgrade();
self.rhs_editor.update(cx, |editor, _cx| {
let this = this.clone();
editor.set_on_local_selections_changed(Some(Box::new(
move |cursor_position, window, cx| {
let this = this.clone();
window.defer(cx, move |window, cx| {
this.update(cx, |this, cx| {
this.sync_cursor_to_other_side(true, cursor_position, window, cx);
})
.ok();
})
},
)));
});
lhs.editor.update(cx, |editor, _cx| {
let this = this.clone();
editor.set_on_local_selections_changed(Some(Box::new(
move |cursor_position, window, cx| {
let this = this.clone();
window.defer(cx, move |window, cx| {
this.update(cx, |this, cx| {
this.sync_cursor_to_other_side(false, cursor_position, window, cx);
})
.ok();
})
},
)));
});
// Copy soft wrap state from rhs (source of truth) to lhs
let rhs_soft_wrap_override = self.rhs_editor.read(cx).soft_wrap_mode_override;
lhs.editor.update(cx, |editor, cx| {
editor.soft_wrap_mode_override = rhs_soft_wrap_override;
cx.notify();
});
cx.notify();
}
fn activate_pane_left(
&mut self,
_: &ActivatePaneLeft,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(lhs) = &self.lhs {
if !lhs.was_last_focused {
lhs.editor.read(cx).focus_handle(cx).focus(window, cx);
lhs.editor.update(cx, |editor, cx| {
editor.request_autoscroll(Autoscroll::fit(), cx);
});
} else {
cx.propagate();
}
} else {
cx.propagate();
}
}
fn activate_pane_right(
&mut self,
_: &ActivatePaneRight,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(lhs) = &self.lhs {
if lhs.was_last_focused {
self.rhs_editor.read(cx).focus_handle(cx).focus(window, cx);
self.rhs_editor.update(cx, |editor, cx| {
editor.request_autoscroll(Autoscroll::fit(), cx);
});
} else {
cx.propagate();
}
} else {
cx.propagate();
}
}
fn sync_cursor_to_other_side(
&mut self,
from_rhs: bool,
source_point: Point,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(lhs) = &self.lhs else {
return;
};
let (source_editor, target_editor) = if from_rhs {
(&self.rhs_editor, &lhs.editor)
} else {
(&lhs.editor, &self.rhs_editor)
};
let source_snapshot = source_editor.update(cx, |editor, cx| editor.snapshot(window, cx));
let target_snapshot = target_editor.update(cx, |editor, cx| editor.snapshot(window, cx));
let display_point = source_snapshot
.display_snapshot
.point_to_display_point(source_point, Bias::Right);
let display_point = target_snapshot.clip_point(display_point, Bias::Right);
let target_point = target_snapshot.display_point_to_point(display_point, Bias::Right);
target_editor.update(cx, |editor, cx| {
editor.set_suppress_selection_callback(true);
editor.change_selections(crate::SelectionEffects::no_scroll(), window, cx, |s| {
s.select_ranges([target_point..target_point]);
});
editor.set_suppress_selection_callback(false);
});
}
pub fn toggle_split(
&mut self,
_: &ToggleSplitDiff,
window: &mut Window,
cx: &mut Context<Self>,
) {
match self.diff_view_style {
DiffViewStyle::Unified => {
self.diff_view_style = DiffViewStyle::Split;
if !self.too_narrow_for_split {
self.split(window, cx);
}
}
DiffViewStyle::Split => {
self.diff_view_style = DiffViewStyle::Unified;
if self.is_split() {
self.unsplit(window, cx);
}
}
}
}
fn intercept_toggle_breakpoint(
&mut self,
_: &ToggleBreakpoint,
_window: &mut Window,
cx: &mut Context<Self>,
) {
// Only block breakpoint actions when the left (lhs) editor has focus
if let Some(lhs) = &self.lhs {
if lhs.was_last_focused {
cx.stop_propagation();
} else {
cx.propagate();
}
} else {
cx.propagate();
}
}
fn intercept_enable_breakpoint(
&mut self,
_: &EnableBreakpoint,
_window: &mut Window,
cx: &mut Context<Self>,
) {
// Only block breakpoint actions when the left (lhs) editor has focus
if let Some(lhs) = &self.lhs {
if lhs.was_last_focused {
cx.stop_propagation();
} else {
cx.propagate();
}
} else {
cx.propagate();
}
}
fn intercept_disable_breakpoint(
&mut self,
_: &DisableBreakpoint,
_window: &mut Window,
cx: &mut Context<Self>,
) {
// Only block breakpoint actions when the left (lhs) editor has focus
if let Some(lhs) = &self.lhs {
if lhs.was_last_focused {
cx.stop_propagation();
} else {
cx.propagate();
}
} else {
cx.propagate();
}
}
fn intercept_edit_log_breakpoint(
&mut self,
_: &EditLogBreakpoint,
_window: &mut Window,
cx: &mut Context<Self>,
) {
// Only block breakpoint actions when the left (lhs) editor has focus
if let Some(lhs) = &self.lhs {
if lhs.was_last_focused {
cx.stop_propagation();
} else {
cx.propagate();
}
} else {
cx.propagate();
}
}
fn intercept_inline_assist(
&mut self,
_: &InlineAssist,
_window: &mut Window,
cx: &mut Context<Self>,
) {
if self.lhs.is_some() {
cx.stop_propagation();
} else {
cx.propagate();
}
}
fn toggle_soft_wrap(
&mut self,
_: &ToggleSoftWrap,
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(lhs) = &self.lhs {
cx.stop_propagation();
let is_lhs_focused = lhs.was_last_focused;
let (focused_editor, other_editor) = if is_lhs_focused {
(&lhs.editor, &self.rhs_editor)
} else {
(&self.rhs_editor, &lhs.editor)
};
// Toggle the focused editor
focused_editor.update(cx, |editor, cx| {
editor.toggle_soft_wrap(&ToggleSoftWrap, window, cx);
});
// Copy the soft wrap state from the focused editor to the other editor
let soft_wrap_override = focused_editor.read(cx).soft_wrap_mode_override;
other_editor.update(cx, |editor, cx| {
editor.soft_wrap_mode_override = soft_wrap_override;
cx.notify();
});
} else {
cx.propagate();
}
}
fn unsplit(&mut self, _: &mut Window, cx: &mut Context<Self>) {
let Some(lhs) = self.lhs.take() else {
return;
};
self.rhs_editor.update(cx, |rhs, cx| {
let rhs_snapshot = rhs.display_map.update(cx, |dm, cx| dm.snapshot(cx));
let native_anchor = rhs.scroll_manager.native_anchor(&rhs_snapshot, cx);
let rhs_display_map_id = rhs_snapshot.display_map_id;
rhs.scroll_manager
.scroll_anchor_entity()
.update(cx, |shared, _| {
shared.scroll_anchor = native_anchor;
shared.display_map_id = Some(rhs_display_map_id);
});
rhs.set_on_local_selections_changed(None);
rhs.set_delegate_expand_excerpts(false);
rhs.buffer().update(cx, |buffer, cx| {
buffer.set_show_deleted_hunks(true, cx);
buffer.set_use_extended_diff_range(false, cx);
});
rhs.display_map.update(cx, |dm, cx| {
dm.set_companion(None, cx);
});
});
lhs.editor.update(cx, |editor, _cx| {
editor.set_on_local_selections_changed(None);
});
cx.notify();
}
pub fn update_excerpts_for_path(
&mut self,
path: PathKey,
buffer: Entity<Buffer>,
ranges: impl IntoIterator<Item = Range<Point>> + Clone,
context_line_count: u32,
diff: Entity<BufferDiff>,
cx: &mut Context<Self>,
) -> bool {
let has_ranges = ranges.clone().into_iter().next().is_some();
let Some(companion) = self.companion(cx) else {
return self.rhs_multibuffer.update(cx, |rhs_multibuffer, cx| {
let added_a_new_excerpt = rhs_multibuffer.update_excerpts_for_path(
path,
buffer.clone(),
ranges,
context_line_count,
cx,
);
if has_ranges
&& rhs_multibuffer
.diff_for(buffer.read(cx).remote_id())
.is_none_or(|old_diff| old_diff.entity_id() != diff.entity_id())
{
rhs_multibuffer.add_diff(diff, cx);
}
added_a_new_excerpt
});
};
let result = self.rhs_multibuffer.update(cx, |rhs_multibuffer, cx| {
let added_a_new_excerpt = rhs_multibuffer.update_excerpts_for_path(
path.clone(),
buffer.clone(),
ranges,
context_line_count,
cx,
);
if has_ranges
&& rhs_multibuffer
.diff_for(buffer.read(cx).remote_id())
.is_none_or(|old_diff| old_diff.entity_id() != diff.entity_id())
{
rhs_multibuffer.add_diff(diff.clone(), cx);
}
added_a_new_excerpt
});
self.sync_lhs_for_paths(vec![(path, diff)], &companion, cx);
result
}
fn expand_excerpts(
&mut self,
excerpt_anchors: impl Iterator<Item = Anchor> + Clone,
lines: u32,
direction: ExpandExcerptDirection,
cx: &mut Context<Self>,
) {
let Some(companion) = self.companion(cx) else {
self.rhs_multibuffer.update(cx, |rhs_multibuffer, cx| {
rhs_multibuffer.expand_excerpts(excerpt_anchors, lines, direction, cx);
});
return;
};
let paths: Vec<_> = self.rhs_multibuffer.update(cx, |rhs_multibuffer, cx| {
let snapshot = rhs_multibuffer.snapshot(cx);
let paths = excerpt_anchors
.clone()
.filter_map(|anchor| {
let (anchor, _) = snapshot.anchor_to_buffer_anchor(anchor)?;
let path = snapshot.path_for_buffer(anchor.buffer_id)?;
let diff = rhs_multibuffer.diff_for(anchor.buffer_id)?;
Some((path.clone(), diff))
})
.collect::<HashMap<_, _>>()
.into_iter()
.collect();
rhs_multibuffer.expand_excerpts(excerpt_anchors, lines, direction, cx);
paths
});
self.sync_lhs_for_paths(paths, &companion, cx);
}
pub fn remove_excerpts_for_path(&mut self, path: PathKey, cx: &mut Context<Self>) {
self.rhs_multibuffer.update(cx, |rhs_multibuffer, cx| {
rhs_multibuffer.remove_excerpts(path.clone(), cx);
});
if let Some(lhs) = &self.lhs {
lhs.multibuffer.update(cx, |lhs_multibuffer, cx| {
lhs_multibuffer.remove_excerpts(path, cx);
});
}
}
fn search_token(&self) -> SearchToken {
SearchToken::new(self.focused_side() as u64)
}
fn editor_for_token(&self, token: SearchToken) -> Option<&Entity<Editor>> {
if token.value() == SplitSide::Left as u64 {
return self.lhs.as_ref().map(|lhs| &lhs.editor);
}
Some(&self.rhs_editor)
}
fn companion(&self, cx: &App) -> Option<Entity<Companion>> {
if self.lhs.is_none() {
return None;
}
let rhs_display_map = self.rhs_editor.read(cx).display_map.clone();
rhs_display_map.read(cx).companion().cloned()
}
fn sync_lhs_for_paths(
&self,
paths: Vec<(PathKey, Entity<BufferDiff>)>,
companion: &Entity<Companion>,
cx: &mut Context<Self>,
) {
let Some(lhs) = &self.lhs else { return };
self.rhs_multibuffer.update(cx, |rhs_multibuffer, cx| {
for (path, diff) in paths {
let main_buffer_id = diff.read(cx).buffer_id;
let Some(main_buffer) = rhs_multibuffer.buffer(diff.read(cx).buffer_id) else {
lhs.multibuffer.update(cx, |lhs_multibuffer, lhs_cx| {
lhs_multibuffer.remove_excerpts(path, lhs_cx);
});
continue;
};
let main_buffer_snapshot = main_buffer.read(cx).snapshot();
let base_text_buffer = diff.read(cx).base_text_buffer().clone();
let diff_snapshot = diff.read(cx).snapshot(cx);
let base_text_buffer_snapshot = base_text_buffer.read(cx).snapshot();
let mut paired_ranges: Vec<(Range<Point>, ExcerptRange<text::Anchor>)> = Vec::new();
let mut have_excerpt = false;
let mut did_merge = false;
let rhs_multibuffer_snapshot = rhs_multibuffer.snapshot(cx);
for info in rhs_multibuffer_snapshot.excerpts_for_buffer(main_buffer_id) {
have_excerpt = true;
let rhs_context = info.context.to_point(&main_buffer_snapshot);
let lhs_context = rhs_range_to_base_text_range(
&rhs_context,
&diff_snapshot,
&main_buffer_snapshot,
);
if let Some((prev_lhs_context, prev_rhs_range)) = paired_ranges.last_mut()
&& prev_lhs_context.end >= lhs_context.start
{
did_merge = true;
prev_lhs_context.end = lhs_context.end;
prev_rhs_range.context.end = info.context.end;
continue;
}
paired_ranges.push((lhs_context, info));
}
let (lhs_ranges, rhs_ranges): (Vec<_>, Vec<_>) = paired_ranges.into_iter().unzip();
let lhs_ranges = lhs_ranges
.into_iter()
.map(|range| {
ExcerptRange::new(base_text_buffer_snapshot.anchor_range_outside(range))
})
.collect::<Vec<_>>();
lhs.multibuffer.update(cx, |lhs_multibuffer, lhs_cx| {
lhs_multibuffer.update_path_excerpts(
path.clone(),
base_text_buffer,
&base_text_buffer_snapshot,
&lhs_ranges,
lhs_cx,
);
if have_excerpt
&& lhs_multibuffer
.diff_for(base_text_buffer_snapshot.remote_id())
.is_none_or(|old_diff| old_diff.entity_id() != diff.entity_id())
{
lhs_multibuffer.add_inverted_diff(
diff.clone(),
main_buffer.clone(),
lhs_cx,
);
}
});
if did_merge {
rhs_multibuffer.update_path_excerpts(
path,
main_buffer,
&main_buffer_snapshot,
&rhs_ranges,
cx,
);
}
let lhs_buffer_id = diff.read(cx).base_text(cx).remote_id();
let rhs_buffer_id = diff.read(cx).buffer_id;
companion.update(cx, |c, _| {
c.add_buffer_mapping(lhs_buffer_id, rhs_buffer_id);
});
}
});
}
fn width_changed(&mut self, width: Pixels, window: &mut Window, cx: &mut Context<Self>) {
self.last_width = Some(width);
let min_ems = EditorSettings::get_global(cx).minimum_split_diff_width;
let style = self.rhs_editor.read(cx).create_style(cx);
let font_id = window.text_system().resolve_font(&style.text.font());
let font_size = style.text.font_size.to_pixels(window.rem_size());
let em_advance = window
.text_system()
.em_advance(font_id, font_size)
.unwrap_or(font_size);
let min_width = em_advance * min_ems;
let is_split = self.lhs.is_some();
self.too_narrow_for_split = min_ems > 0.0 && width < min_width;
match self.diff_view_style {
DiffViewStyle::Unified => {}
DiffViewStyle::Split => {
if self.too_narrow_for_split && is_split {
self.unsplit(window, cx);
} else if !self.too_narrow_for_split && !is_split {
self.split(window, cx);
}
}
}
}
}
#[cfg(test)]
impl SplittableEditor {
fn check_invariants(&self, quiesced: bool, cx: &mut App) {
use text::Bias;
use crate::display_map::Block;
use crate::display_map::DisplayRow;
self.debug_print(cx);
self.check_excerpt_invariants(quiesced, cx);
let lhs = self.lhs.as_ref().unwrap();
if quiesced {
let lhs_snapshot = lhs
.editor
.update(cx, |editor, cx| editor.display_snapshot(cx));
let rhs_snapshot = self
.rhs_editor
.update(cx, |editor, cx| editor.display_snapshot(cx));
let lhs_max_row = lhs_snapshot.max_point().row();
let rhs_max_row = rhs_snapshot.max_point().row();
assert_eq!(lhs_max_row, rhs_max_row, "mismatch in display row count");
let lhs_excerpt_block_rows = lhs_snapshot
.blocks_in_range(DisplayRow(0)..lhs_max_row + 1)
.filter(|(_, block)| {
matches!(
block,
Block::BufferHeader { .. } | Block::ExcerptBoundary { .. }
)
})
.map(|(row, _)| row)
.collect::<Vec<_>>();
let rhs_excerpt_block_rows = rhs_snapshot
.blocks_in_range(DisplayRow(0)..rhs_max_row + 1)
.filter(|(_, block)| {
matches!(
block,
Block::BufferHeader { .. } | Block::ExcerptBoundary { .. }
)
})
.map(|(row, _)| row)
.collect::<Vec<_>>();
assert_eq!(lhs_excerpt_block_rows, rhs_excerpt_block_rows);
for (lhs_hunk, rhs_hunk) in lhs_snapshot.diff_hunks().zip(rhs_snapshot.diff_hunks()) {
assert_eq!(
lhs_hunk.diff_base_byte_range, rhs_hunk.diff_base_byte_range,
"mismatch in hunks"
);
assert_eq!(
lhs_hunk.status, rhs_hunk.status,
"mismatch in hunk statuses"
);
let (lhs_point, rhs_point) =
if lhs_hunk.row_range.is_empty() || rhs_hunk.row_range.is_empty() {
use multi_buffer::ToPoint as _;
let lhs_end = Point::new(lhs_hunk.row_range.end.0, 0);
let rhs_end = Point::new(rhs_hunk.row_range.end.0, 0);
let lhs_excerpt_end = lhs_snapshot
.anchor_in_excerpt(lhs_hunk.excerpt_range.context.end)
.unwrap()
.to_point(&lhs_snapshot);
let lhs_exceeds = lhs_end >= lhs_excerpt_end;
let rhs_excerpt_end = rhs_snapshot
.anchor_in_excerpt(rhs_hunk.excerpt_range.context.end)
.unwrap()
.to_point(&rhs_snapshot);
let rhs_exceeds = rhs_end >= rhs_excerpt_end;
if lhs_exceeds != rhs_exceeds {
continue;
}
(lhs_end, rhs_end)
} else {
(
Point::new(lhs_hunk.row_range.start.0, 0),
Point::new(rhs_hunk.row_range.start.0, 0),
)
};
let lhs_point = lhs_snapshot.point_to_display_point(lhs_point, Bias::Left);
let rhs_point = rhs_snapshot.point_to_display_point(rhs_point, Bias::Left);
assert_eq!(
lhs_point.row(),
rhs_point.row(),
"mismatch in hunk position"
);
}
}
}
fn debug_print(&self, cx: &mut App) {
use crate::DisplayRow;
use crate::display_map::Block;
use buffer_diff::DiffHunkStatusKind;
assert!(
self.lhs.is_some(),
"debug_print is only useful when lhs editor exists"
);
let lhs = self.lhs.as_ref().unwrap();
// Get terminal width, default to 80 if unavailable
let terminal_width = std::env::var("COLUMNS")
.ok()
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(80);
// Each side gets half the terminal width minus the separator
let separator = "";
let side_width = (terminal_width - separator.len()) / 2;
// Get display snapshots for both editors
let lhs_snapshot = lhs.editor.update(cx, |editor, cx| {
editor.display_map.update(cx, |map, cx| map.snapshot(cx))
});
let rhs_snapshot = self.rhs_editor.update(cx, |editor, cx| {
editor.display_map.update(cx, |map, cx| map.snapshot(cx))
});
let lhs_max_row = lhs_snapshot.max_point().row().0;
let rhs_max_row = rhs_snapshot.max_point().row().0;
let max_row = lhs_max_row.max(rhs_max_row);
// Build a map from display row -> block type string
// Each row of a multi-row block gets an entry with the same block type
// For spacers, the ID is included in brackets
fn build_block_map(
snapshot: &crate::DisplaySnapshot,
max_row: u32,
) -> std::collections::HashMap<u32, String> {
let mut block_map = std::collections::HashMap::new();
for (start_row, block) in
snapshot.blocks_in_range(DisplayRow(0)..DisplayRow(max_row + 1))
{
let (block_type, height) = match block {
Block::Spacer {
id,
height,
is_below: _,
} => (format!("SPACER[{}]", id.0), *height),
Block::ExcerptBoundary { height, .. } => {
("EXCERPT_BOUNDARY".to_string(), *height)
}
Block::BufferHeader { height, .. } => ("BUFFER_HEADER".to_string(), *height),
Block::FoldedBuffer { height, .. } => ("FOLDED_BUFFER".to_string(), *height),
Block::Custom(custom) => {
("CUSTOM_BLOCK".to_string(), custom.height.unwrap_or(1))
}
};
for offset in 0..height {
block_map.insert(start_row.0 + offset, block_type.clone());
}
}
block_map
}
let lhs_blocks = build_block_map(&lhs_snapshot, lhs_max_row);
let rhs_blocks = build_block_map(&rhs_snapshot, rhs_max_row);
fn display_width(s: &str) -> usize {
unicode_width::UnicodeWidthStr::width(s)
}
fn truncate_line(line: &str, max_width: usize) -> String {
let line_width = display_width(line);
if line_width <= max_width {
return line.to_string();
}
if max_width < 9 {
let mut result = String::new();
let mut width = 0;
for c in line.chars() {
let c_width = unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
if width + c_width > max_width {
break;
}
result.push(c);
width += c_width;
}
return result;
}
let ellipsis = "...";
let target_prefix_width = 3;
let target_suffix_width = 3;
let mut prefix = String::new();
let mut prefix_width = 0;
for c in line.chars() {
let c_width = unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
if prefix_width + c_width > target_prefix_width {
break;
}
prefix.push(c);
prefix_width += c_width;
}
let mut suffix_chars: Vec<char> = Vec::new();
let mut suffix_width = 0;
for c in line.chars().rev() {
let c_width = unicode_width::UnicodeWidthChar::width(c).unwrap_or(0);
if suffix_width + c_width > target_suffix_width {
break;
}
suffix_chars.push(c);
suffix_width += c_width;
}
suffix_chars.reverse();
let suffix: String = suffix_chars.into_iter().collect();
format!("{}{}{}", prefix, ellipsis, suffix)
}
fn pad_to_width(s: &str, target_width: usize) -> String {
let current_width = display_width(s);
if current_width >= target_width {
s.to_string()
} else {
format!("{}{}", s, " ".repeat(target_width - current_width))
}
}
// Helper to format a single row for one side
// Format: "ln# diff bytes(cumul) text" or block info
// Line numbers come from buffer_row in RowInfo (1-indexed for display)
fn format_row(
row: u32,
max_row: u32,
snapshot: &crate::DisplaySnapshot,
blocks: &std::collections::HashMap<u32, String>,
row_infos: &[multi_buffer::RowInfo],
cumulative_bytes: &[usize],
side_width: usize,
) -> String {
// Get row info if available
let row_info = row_infos.get(row as usize);
// Line number prefix (3 chars + space)
// Use buffer_row from RowInfo, which is None for block rows
let line_prefix = if row > max_row {
" ".to_string()
} else if let Some(buffer_row) = row_info.and_then(|info| info.buffer_row) {
format!("{:>3} ", buffer_row + 1) // 1-indexed for display
} else {
" ".to_string() // block rows have no line number
};
let content_width = side_width.saturating_sub(line_prefix.len());
if row > max_row {
return format!("{}{}", line_prefix, " ".repeat(content_width));
}
// Check if this row is a block row
if let Some(block_type) = blocks.get(&row) {
let block_str = format!("~~~[{}]~~~", block_type);
let formatted = format!("{:^width$}", block_str, width = content_width);
return format!(
"{}{}",
line_prefix,
truncate_line(&formatted, content_width)
);
}
// Get line text
let line_text = snapshot.line(DisplayRow(row));
let line_bytes = line_text.len();
// Diff status marker
let diff_marker = match row_info.and_then(|info| info.diff_status.as_ref()) {
Some(status) => match status.kind {
DiffHunkStatusKind::Added => "+",
DiffHunkStatusKind::Deleted => "-",
DiffHunkStatusKind::Modified => "~",
},
None => " ",
};
// Cumulative bytes
let cumulative = cumulative_bytes.get(row as usize).copied().unwrap_or(0);
// Format: "diff bytes(cumul) text" - use 3 digits for bytes, 4 for cumulative
let info_prefix = format!("{}{:>3}({:>4}) ", diff_marker, line_bytes, cumulative);
let text_width = content_width.saturating_sub(info_prefix.len());
let truncated_text = truncate_line(&line_text, text_width);
let text_part = pad_to_width(&truncated_text, text_width);
format!("{}{}{}", line_prefix, info_prefix, text_part)
}
// Collect row infos for both sides
let lhs_row_infos: Vec<_> = lhs_snapshot
.row_infos(DisplayRow(0))
.take((lhs_max_row + 1) as usize)
.collect();
let rhs_row_infos: Vec<_> = rhs_snapshot
.row_infos(DisplayRow(0))
.take((rhs_max_row + 1) as usize)
.collect();
// Calculate cumulative bytes for each side (only counting non-block rows)
let mut lhs_cumulative = Vec::with_capacity((lhs_max_row + 1) as usize);
let mut cumulative = 0usize;
for row in 0..=lhs_max_row {
if !lhs_blocks.contains_key(&row) {
cumulative += lhs_snapshot.line(DisplayRow(row)).len() + 1; // +1 for newline
}
lhs_cumulative.push(cumulative);
}
let mut rhs_cumulative = Vec::with_capacity((rhs_max_row + 1) as usize);
cumulative = 0;
for row in 0..=rhs_max_row {
if !rhs_blocks.contains_key(&row) {
cumulative += rhs_snapshot.line(DisplayRow(row)).len() + 1;
}
rhs_cumulative.push(cumulative);
}
// Print header
eprintln!();
eprintln!("{}", "".repeat(terminal_width));
let header_left = format!("{:^width$}", "(LHS)", width = side_width);
let header_right = format!("{:^width$}", "(RHS)", width = side_width);
eprintln!("{}{}{}", header_left, separator, header_right);
eprintln!(
"{:^width$}{}{:^width$}",
"ln# diff len(cum) text",
separator,
"ln# diff len(cum) text",
width = side_width
);
eprintln!("{}", "".repeat(terminal_width));
// Print each row
for row in 0..=max_row {
let left = format_row(
row,
lhs_max_row,
&lhs_snapshot,
&lhs_blocks,
&lhs_row_infos,
&lhs_cumulative,
side_width,
);
let right = format_row(
row,
rhs_max_row,
&rhs_snapshot,
&rhs_blocks,
&rhs_row_infos,
&rhs_cumulative,
side_width,
);
eprintln!("{}{}{}", left, separator, right);
}
eprintln!("{}", "".repeat(terminal_width));
eprintln!("Legend: + added, - deleted, ~ modified, ~~~ block/spacer row");
eprintln!();
}
fn check_excerpt_invariants(&self, quiesced: bool, cx: &gpui::App) {
let lhs = self.lhs.as_ref().expect("should have lhs editor");
let rhs_snapshot = self.rhs_multibuffer.read(cx).snapshot(cx);
let rhs_excerpts = rhs_snapshot.excerpts().collect::<Vec<_>>();
let lhs_snapshot = lhs.multibuffer.read(cx).snapshot(cx);
let lhs_excerpts = lhs_snapshot.excerpts().collect::<Vec<_>>();
assert_eq!(lhs_excerpts.len(), rhs_excerpts.len());
for (lhs_excerpt, rhs_excerpt) in lhs_excerpts.into_iter().zip(rhs_excerpts) {
assert_eq!(
lhs_snapshot
.path_for_buffer(lhs_excerpt.context.start.buffer_id)
.unwrap(),
rhs_snapshot
.path_for_buffer(rhs_excerpt.context.start.buffer_id)
.unwrap(),
"corresponding excerpts should have the same path"
);
let diff = self
.rhs_multibuffer
.read(cx)
.diff_for(rhs_excerpt.context.start.buffer_id)
.expect("missing diff");
assert_eq!(
lhs_excerpt.context.start.buffer_id,
diff.read(cx).base_text(cx).remote_id(),
"corresponding lhs excerpt should show diff base text"
);
if quiesced {
let diff_snapshot = diff.read(cx).snapshot(cx);
let lhs_buffer_snapshot = lhs_snapshot
.buffer_for_id(lhs_excerpt.context.start.buffer_id)
.unwrap();
let rhs_buffer_snapshot = rhs_snapshot
.buffer_for_id(rhs_excerpt.context.start.buffer_id)
.unwrap();
let lhs_range = lhs_excerpt.context.to_point(&lhs_buffer_snapshot);
let rhs_range = rhs_excerpt.context.to_point(&rhs_buffer_snapshot);
let expected_lhs_range =
rhs_range_to_base_text_range(&rhs_range, &diff_snapshot, &rhs_buffer_snapshot);
assert_eq!(
lhs_range, expected_lhs_range,
"corresponding lhs excerpt should have a matching range"
)
}
}
}
}
impl Item for SplittableEditor {
type Event = EditorEvent;
fn tab_content_text(&self, detail: usize, cx: &App) -> ui::SharedString {
self.rhs_editor.read(cx).tab_content_text(detail, cx)
}
fn tab_tooltip_text(&self, cx: &App) -> Option<ui::SharedString> {
self.rhs_editor.read(cx).tab_tooltip_text(cx)
}
fn tab_icon(&self, window: &Window, cx: &App) -> Option<ui::Icon> {
self.rhs_editor.read(cx).tab_icon(window, cx)
}
fn tab_content(&self, params: TabContentParams, window: &Window, cx: &App) -> gpui::AnyElement {
self.rhs_editor.read(cx).tab_content(params, window, cx)
}
fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) {
Editor::to_item_events(event, f)
}
fn for_each_project_item(
&self,
cx: &App,
f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
) {
self.rhs_editor.read(cx).for_each_project_item(cx, f)
}
fn buffer_kind(&self, cx: &App) -> ItemBufferKind {
self.rhs_editor.read(cx).buffer_kind(cx)
}
fn is_dirty(&self, cx: &App) -> bool {
self.rhs_editor.read(cx).is_dirty(cx)
}
fn has_conflict(&self, cx: &App) -> bool {
self.rhs_editor.read(cx).has_conflict(cx)
}
fn has_deleted_file(&self, cx: &App) -> bool {
self.rhs_editor.read(cx).has_deleted_file(cx)
}
fn capability(&self, cx: &App) -> language::Capability {
self.rhs_editor.read(cx).capability(cx)
}
fn can_save(&self, cx: &App) -> bool {
self.rhs_editor.read(cx).can_save(cx)
}
fn can_save_as(&self, cx: &App) -> bool {
self.rhs_editor.read(cx).can_save_as(cx)
}
fn save(
&mut self,
options: SaveOptions,
project: Entity<Project>,
window: &mut Window,
cx: &mut Context<Self>,
) -> gpui::Task<anyhow::Result<()>> {
self.rhs_editor
.update(cx, |editor, cx| editor.save(options, project, window, cx))
}
fn save_as(
&mut self,
project: Entity<Project>,
path: project::ProjectPath,
window: &mut Window,
cx: &mut Context<Self>,
) -> gpui::Task<anyhow::Result<()>> {
self.rhs_editor
.update(cx, |editor, cx| editor.save_as(project, path, window, cx))
}
fn reload(
&mut self,
project: Entity<Project>,
window: &mut Window,
cx: &mut Context<Self>,
) -> gpui::Task<anyhow::Result<()>> {
self.rhs_editor
.update(cx, |editor, cx| editor.reload(project, window, cx))
}
fn navigate(
&mut self,
data: Arc<dyn std::any::Any + Send>,
window: &mut Window,
cx: &mut Context<Self>,
) -> bool {
self.focused_editor()
.update(cx, |editor, cx| editor.navigate(data, window, cx))
}
fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.focused_editor().update(cx, |editor, cx| {
editor.deactivated(window, cx);
});
}
fn added_to_workspace(
&mut self,
workspace: &mut Workspace,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.workspace = workspace.weak_handle();
self.rhs_editor.update(cx, |rhs_editor, cx| {
rhs_editor.added_to_workspace(workspace, window, cx);
});
if let Some(lhs) = &self.lhs {
lhs.editor.update(cx, |lhs_editor, cx| {
lhs_editor.added_to_workspace(workspace, window, cx);
});
}
}
fn as_searchable(
&self,
handle: &Entity<Self>,
_: &App,
) -> Option<Box<dyn SearchableItemHandle>> {
Some(Box::new(handle.clone()))
}
fn breadcrumb_location(&self, cx: &App) -> ToolbarItemLocation {
self.rhs_editor.read(cx).breadcrumb_location(cx)
}
fn breadcrumbs(&self, cx: &App) -> Option<(Vec<HighlightedText>, Option<Font>)> {
self.rhs_editor.read(cx).breadcrumbs(cx)
}
fn pixel_position_of_cursor(&self, cx: &App) -> Option<gpui::Point<gpui::Pixels>> {
self.focused_editor().read(cx).pixel_position_of_cursor(cx)
}
fn act_as_type<'a>(
&'a self,
type_id: std::any::TypeId,
self_handle: &'a Entity<Self>,
_: &'a App,
) -> Option<gpui::AnyEntity> {
if type_id == std::any::TypeId::of::<Self>() {
Some(self_handle.clone().into())
} else if type_id == std::any::TypeId::of::<Editor>() {
Some(self.rhs_editor.clone().into())
} else {
None
}
}
}
impl SearchableItem for SplittableEditor {
type Match = Range<Anchor>;
fn clear_matches(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.rhs_editor.update(cx, |editor, cx| {
editor.clear_matches(window, cx);
});
if let Some(lhs_editor) = self.lhs_editor() {
lhs_editor.update(cx, |editor, cx| {
editor.clear_matches(window, cx);
})
}
}
fn update_matches(
&mut self,
matches: &[Self::Match],
active_match_index: Option<usize>,
token: SearchToken,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(target) = self.editor_for_token(token) else {
return;
};
target.update(cx, |editor, cx| {
editor.update_matches(matches, active_match_index, token, window, cx);
});
}
fn search_bar_visibility_changed(
&mut self,
visible: bool,
window: &mut Window,
cx: &mut Context<Self>,
) {
if visible {
let side = self.focused_side();
self.searched_side = Some(side);
match side {
SplitSide::Left => {
self.rhs_editor.update(cx, |editor, cx| {
editor.clear_matches(window, cx);
});
}
SplitSide::Right => {
if let Some(lhs) = &self.lhs {
lhs.editor.update(cx, |editor, cx| {
editor.clear_matches(window, cx);
});
}
}
}
} else {
self.searched_side = None;
}
}
fn query_suggestion(&mut self, window: &mut Window, cx: &mut Context<Self>) -> String {
self.focused_editor()
.update(cx, |editor, cx| editor.query_suggestion(window, cx))
}
fn activate_match(
&mut self,
index: usize,
matches: &[Self::Match],
token: SearchToken,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(target) = self.editor_for_token(token) else {
return;
};
target.update(cx, |editor, cx| {
editor.activate_match(index, matches, token, window, cx);
});
}
fn select_matches(
&mut self,
matches: &[Self::Match],
token: SearchToken,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(target) = self.editor_for_token(token) else {
return;
};
target.update(cx, |editor, cx| {
editor.select_matches(matches, token, window, cx);
});
}
fn replace(
&mut self,
identifier: &Self::Match,
query: &project::search::SearchQuery,
token: SearchToken,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(target) = self.editor_for_token(token) else {
return;
};
target.update(cx, |editor, cx| {
editor.replace(identifier, query, token, window, cx);
});
}
fn find_matches(
&mut self,
query: Arc<project::search::SearchQuery>,
window: &mut Window,
cx: &mut Context<Self>,
) -> gpui::Task<Vec<Self::Match>> {
self.focused_editor()
.update(cx, |editor, cx| editor.find_matches(query, window, cx))
}
fn find_matches_with_token(
&mut self,
query: Arc<project::search::SearchQuery>,
window: &mut Window,
cx: &mut Context<Self>,
) -> gpui::Task<(Vec<Self::Match>, SearchToken)> {
let token = self.search_token();
let editor = self.focused_editor().downgrade();
cx.spawn_in(window, async move |_, cx| {
let Some(matches) = editor
.update_in(cx, |editor, window, cx| {
editor.find_matches(query, window, cx)
})
.ok()
else {
return (Vec::new(), token);
};
(matches.await, token)
})
}
fn active_match_index(
&mut self,
direction: workspace::searchable::Direction,
matches: &[Self::Match],
token: SearchToken,
window: &mut Window,
cx: &mut Context<Self>,
) -> Option<usize> {
self.editor_for_token(token)?.update(cx, |editor, cx| {
editor.active_match_index(direction, matches, token, window, cx)
})
}
}
impl EventEmitter<EditorEvent> for SplittableEditor {}
impl EventEmitter<SearchEvent> for SplittableEditor {}
impl Focusable for SplittableEditor {
fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
self.focused_editor().read(cx).focus_handle(cx)
}
}
impl Render for SplittableEditor {
fn render(
&mut self,
_window: &mut ui::Window,
cx: &mut ui::Context<Self>,
) -> impl ui::IntoElement {
let is_split = self.lhs.is_some();
let inner = if is_split {
let style = self.rhs_editor.read(cx).create_style(cx);
SplitEditorView::new(cx.entity(), style, self.split_state.clone()).into_any_element()
} else {
self.rhs_editor.clone().into_any_element()
};
let this = cx.entity().downgrade();
let last_width = self.last_width;
div()
.id("splittable-editor")
.on_action(cx.listener(Self::toggle_split))
.on_action(cx.listener(Self::activate_pane_left))
.on_action(cx.listener(Self::activate_pane_right))
.on_action(cx.listener(Self::intercept_toggle_breakpoint))
.on_action(cx.listener(Self::intercept_enable_breakpoint))
.on_action(cx.listener(Self::intercept_disable_breakpoint))
.on_action(cx.listener(Self::intercept_edit_log_breakpoint))
.on_action(cx.listener(Self::intercept_inline_assist))
.capture_action(cx.listener(Self::toggle_soft_wrap))
.size_full()
.child(inner)
.child(
canvas(
move |bounds, window, cx| {
let width = bounds.size.width;
if last_width == Some(width) {
return;
}
window.defer(cx, move |window, cx| {
this.update(cx, |this, cx| {
this.width_changed(width, window, cx);
})
.ok();
});
},
|_, _, _, _| {},
)
.absolute()
.size_full(),
)
}
}
#[cfg(test)]
mod tests {
use std::{any::TypeId, sync::Arc};
use buffer_diff::BufferDiff;
use collections::{HashMap, HashSet};
use fs::FakeFs;
use gpui::Element as _;
use gpui::{AppContext as _, Entity, Pixels, VisualTestContext};
use language::language_settings::SoftWrap;
use language::{Buffer, Capability};
use multi_buffer::{MultiBuffer, PathKey};
use pretty_assertions::assert_eq;
use project::Project;
use rand::rngs::StdRng;
use settings::{DiffViewStyle, SettingsStore};
use ui::{VisualContext as _, div, px};
use util::rel_path::rel_path;
use workspace::{Item, MultiWorkspace};
use crate::display_map::{
BlockPlacement, BlockProperties, BlockStyle, Crease, FoldPlaceholder,
};
use crate::inlays::Inlay;
use crate::test::{editor_content_with_blocks_and_width, set_block_content_for_tests};
use crate::{Editor, SplittableEditor};
use multi_buffer::MultiBufferOffset;
async fn init_test(
cx: &mut gpui::TestAppContext,
soft_wrap: SoftWrap,
style: DiffViewStyle,
) -> (Entity<SplittableEditor>, &mut VisualTestContext) {
cx.update(|cx| {
let store = SettingsStore::test(cx);
cx.set_global(store);
theme_settings::init(theme::LoadThemes::JustBase, cx);
crate::init(cx);
});
let project = Project::test(FakeFs::new(cx.executor()), [], cx).await;
let (multi_workspace, cx) =
cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
let rhs_multibuffer = cx.new(|cx| {
let mut multibuffer = MultiBuffer::new(Capability::ReadWrite);
multibuffer.set_all_diff_hunks_expanded(cx);
multibuffer
});
let editor = cx.new_window_entity(|window, cx| {
let editor = SplittableEditor::new(
style,
rhs_multibuffer.clone(),
project.clone(),
workspace,
window,
cx,
);
editor.rhs_editor.update(cx, |editor, cx| {
editor.set_soft_wrap_mode(soft_wrap, cx);
});
if let Some(lhs) = &editor.lhs {
lhs.editor.update(cx, |editor, cx| {
editor.set_soft_wrap_mode(soft_wrap, cx);
});
}
editor
});
(editor, cx)
}
fn buffer_with_diff(
base_text: &str,
current_text: &str,
cx: &mut VisualTestContext,
) -> (Entity<Buffer>, Entity<BufferDiff>) {
let buffer = cx.new(|cx| Buffer::local(current_text.to_string(), cx));
let diff = cx.new(|cx| {
BufferDiff::new_with_base_text(base_text, &buffer.read(cx).text_snapshot(), cx)
});
(buffer, diff)
}
#[track_caller]
fn assert_split_content(
editor: &Entity<SplittableEditor>,
expected_rhs: String,
expected_lhs: String,
cx: &mut VisualTestContext,
) {
assert_split_content_with_widths(
editor,
px(3000.0),
px(3000.0),
expected_rhs,
expected_lhs,
cx,
);
}
#[track_caller]
fn assert_split_content_with_widths(
editor: &Entity<SplittableEditor>,
rhs_width: Pixels,
lhs_width: Pixels,
expected_rhs: String,
expected_lhs: String,
cx: &mut VisualTestContext,
) {
let (rhs_editor, lhs_editor) = editor.update(cx, |editor, _cx| {
let lhs = editor.lhs.as_ref().expect("should have lhs editor");
(editor.rhs_editor.clone(), lhs.editor.clone())
});
// Make sure both sides learn if the other has soft-wrapped
let _ = editor_content_with_blocks_and_width(&rhs_editor, rhs_width, cx);
cx.run_until_parked();
let _ = editor_content_with_blocks_and_width(&lhs_editor, lhs_width, cx);
cx.run_until_parked();
let rhs_content = editor_content_with_blocks_and_width(&rhs_editor, rhs_width, cx);
let lhs_content = editor_content_with_blocks_and_width(&lhs_editor, lhs_width, cx);
if rhs_content != expected_rhs || lhs_content != expected_lhs {
editor.update(cx, |editor, cx| editor.debug_print(cx));
}
assert_eq!(rhs_content, expected_rhs, "rhs");
assert_eq!(lhs_content, expected_lhs, "lhs");
}
#[gpui::test(iterations = 25)]
async fn test_random_split_editor(mut rng: StdRng, cx: &mut gpui::TestAppContext) {
use multi_buffer::ExpandExcerptDirection;
use rand::prelude::*;
use util::RandomCharIter;
let (editor, cx) = init_test(cx, SoftWrap::EditorWidth, DiffViewStyle::Split).await;
let operations = std::env::var("OPERATIONS")
.map(|i| i.parse().expect("invalid `OPERATIONS` variable"))
.unwrap_or(10);
let rng = &mut rng;
for _ in 0..operations {
let buffers = editor.update(cx, |editor, cx| {
editor.rhs_editor.read(cx).buffer().read(cx).all_buffers()
});
if buffers.is_empty() {
log::info!("creating initial buffer");
let len = rng.random_range(200..1000);
let base_text: String = RandomCharIter::new(&mut *rng).take(len).collect();
let buffer = cx.new(|cx| Buffer::local(base_text.clone(), cx));
let buffer_snapshot = buffer.read_with(cx, |b, _| b.text_snapshot());
let diff =
cx.new(|cx| BufferDiff::new_with_base_text(&base_text, &buffer_snapshot, cx));
let edit_count = rng.random_range(3..8);
buffer.update(cx, |buffer, cx| {
buffer.randomly_edit(rng, edit_count, cx);
});
let buffer_snapshot = buffer.read_with(cx, |b, _| b.text_snapshot());
diff.update(cx, |diff, cx| {
diff.recalculate_diff_sync(&buffer_snapshot, cx);
});
let diff_snapshot = diff.read_with(cx, |diff, cx| diff.snapshot(cx));
let ranges = diff_snapshot
.hunks(&buffer_snapshot)
.map(|hunk| hunk.range)
.collect::<Vec<_>>();
let context_lines = rng.random_range(0..2);
editor.update(cx, |editor, cx| {
let path = PathKey::for_buffer(&buffer, cx);
editor.update_excerpts_for_path(path, buffer, ranges, context_lines, diff, cx);
});
editor.update(cx, |editor, cx| {
editor.check_invariants(true, cx);
});
continue;
}
let mut quiesced = false;
match rng.random_range(0..100) {
0..=14 if buffers.len() < 6 => {
log::info!("creating new buffer and setting excerpts");
let len = rng.random_range(200..1000);
let base_text: String = RandomCharIter::new(&mut *rng).take(len).collect();
let buffer = cx.new(|cx| Buffer::local(base_text.clone(), cx));
let buffer_snapshot = buffer.read_with(cx, |b, _| b.text_snapshot());
let diff = cx
.new(|cx| BufferDiff::new_with_base_text(&base_text, &buffer_snapshot, cx));
let edit_count = rng.random_range(3..8);
buffer.update(cx, |buffer, cx| {
buffer.randomly_edit(rng, edit_count, cx);
});
let buffer_snapshot = buffer.read_with(cx, |b, _| b.text_snapshot());
diff.update(cx, |diff, cx| {
diff.recalculate_diff_sync(&buffer_snapshot, cx);
});
let diff_snapshot = diff.read_with(cx, |diff, cx| diff.snapshot(cx));
let ranges = diff_snapshot
.hunks(&buffer_snapshot)
.map(|hunk| hunk.range)
.collect::<Vec<_>>();
let context_lines = rng.random_range(0..2);
editor.update(cx, |editor, cx| {
let path = PathKey::for_buffer(&buffer, cx);
editor.update_excerpts_for_path(
path,
buffer,
ranges,
context_lines,
diff,
cx,
);
});
}
15..=29 => {
log::info!("randomly editing multibuffer");
let edit_count = rng.random_range(1..5);
editor.update(cx, |editor, cx| {
editor.rhs_multibuffer.update(cx, |multibuffer, cx| {
multibuffer.randomly_edit(rng, edit_count, cx);
});
});
}
30..=44 => {
log::info!("randomly editing individual buffer");
let buffer = buffers.iter().choose(rng).unwrap();
let edit_count = rng.random_range(1..3);
buffer.update(cx, |buffer, cx| {
buffer.randomly_edit(rng, edit_count, cx);
});
}
45..=54 => {
log::info!("recalculating diff and resetting excerpts for single buffer");
let buffer = buffers.iter().choose(rng).unwrap();
let buffer_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot());
let diff = editor.update(cx, |editor, cx| {
editor
.rhs_multibuffer
.read(cx)
.diff_for(buffer.read(cx).remote_id())
.unwrap()
});
diff.update(cx, |diff, cx| {
diff.recalculate_diff_sync(&buffer_snapshot, cx);
});
cx.run_until_parked();
let diff_snapshot = diff.read_with(cx, |diff, cx| diff.snapshot(cx));
let ranges = diff_snapshot
.hunks(&buffer_snapshot)
.map(|hunk| hunk.range)
.collect::<Vec<_>>();
let context_lines = rng.random_range(0..2);
let buffer = buffer.clone();
editor.update(cx, |editor, cx| {
let path = PathKey::for_buffer(&buffer, cx);
editor.update_excerpts_for_path(
path,
buffer,
ranges,
context_lines,
diff,
cx,
);
});
}
55..=64 => {
log::info!("randomly undoing/redoing in single buffer");
let buffer = buffers.iter().choose(rng).unwrap();
buffer.update(cx, |buffer, cx| {
buffer.randomly_undo_redo(rng, cx);
});
}
65..=74 => {
log::info!("removing excerpts for a random path");
let ids = editor.update(cx, |editor, cx| {
let snapshot = editor.rhs_multibuffer.read(cx).snapshot(cx);
snapshot.all_buffer_ids().collect::<Vec<_>>()
});
if let Some(id) = ids.choose(rng) {
editor.update(cx, |editor, cx| {
let snapshot = editor.rhs_multibuffer.read(cx).snapshot(cx);
let path = snapshot.path_for_buffer(*id).unwrap();
editor.remove_excerpts_for_path(path.clone(), cx);
});
}
}
75..=79 => {
log::info!("unsplit and resplit");
editor.update_in(cx, |editor, window, cx| {
editor.unsplit(window, cx);
});
cx.run_until_parked();
editor.update_in(cx, |editor, window, cx| {
editor.split(window, cx);
});
}
80..=89 => {
let snapshot = editor.update(cx, |editor, cx| {
editor.rhs_multibuffer.read(cx).snapshot(cx)
});
let excerpts = snapshot.excerpts().collect::<Vec<_>>();
if !excerpts.is_empty() {
let count = rng.random_range(1..=excerpts.len().min(3));
let chosen: Vec<_> =
excerpts.choose_multiple(rng, count).cloned().collect();
let line_count = rng.random_range(1..5);
log::info!("expanding {count} excerpts by {line_count} lines");
editor.update(cx, |editor, cx| {
editor.expand_excerpts(
chosen.into_iter().map(|excerpt| {
snapshot.anchor_in_excerpt(excerpt.context.start).unwrap()
}),
line_count,
ExpandExcerptDirection::UpAndDown,
cx,
);
});
}
}
_ => {
log::info!("quiescing");
for buffer in buffers {
let buffer_snapshot =
buffer.read_with(cx, |buffer, _| buffer.text_snapshot());
let diff = editor.update(cx, |editor, cx| {
editor
.rhs_multibuffer
.read(cx)
.diff_for(buffer.read(cx).remote_id())
.unwrap()
});
diff.update(cx, |diff, cx| {
diff.recalculate_diff_sync(&buffer_snapshot, cx);
});
cx.run_until_parked();
let diff_snapshot = diff.read_with(cx, |diff, cx| diff.snapshot(cx));
let ranges = diff_snapshot
.hunks(&buffer_snapshot)
.map(|hunk| hunk.range)
.collect::<Vec<_>>();
editor.update(cx, |editor, cx| {
let path = PathKey::for_buffer(&buffer, cx);
editor.update_excerpts_for_path(path, buffer, ranges, 2, diff, cx);
});
}
quiesced = true;
}
}
editor.update(cx, |editor, cx| {
editor.check_invariants(quiesced, cx);
});
}
}
#[gpui::test]
async fn test_expand_excerpt_with_hunk_before_excerpt_start(cx: &mut gpui::TestAppContext) {
use rope::Point;
let (editor, cx) = init_test(cx, SoftWrap::None, DiffViewStyle::Split).await;
let base_text = "aaaaaaa rest_of_line\nsecond_line\nthird_line\nfourth_line";
let current_text = "aaaaaaa rest_of_line\nsecond_line\nMODIFIED\nfourth_line";
let (buffer, diff) = buffer_with_diff(base_text, current_text, cx);
let buffer_snapshot = buffer.read_with(cx, |b, _| b.text_snapshot());
diff.update(cx, |diff, cx| {
diff.recalculate_diff_sync(&buffer_snapshot, cx);
});
cx.run_until_parked();
let diff_snapshot = diff.read_with(cx, |diff, cx| diff.snapshot(cx));
let ranges = diff_snapshot
.hunks(&buffer_snapshot)
.map(|hunk| hunk.range)
.collect::<Vec<_>>();
editor.update(cx, |editor, cx| {
let path = PathKey::for_buffer(&buffer, cx);
editor.update_excerpts_for_path(path, buffer.clone(), ranges, 0, diff.clone(), cx);
});
cx.run_until_parked();
buffer.update(cx, |buffer, cx| {
buffer.edit(
[(Point::new(0, 7)..Point::new(1, 7), "\nnew_line\n")],
None,
cx,
);
});
let excerpts = editor.update(cx, |editor, cx| {
let snapshot = editor.rhs_multibuffer.read(cx).snapshot(cx);
snapshot
.excerpts()
.map(|excerpt| snapshot.anchor_in_excerpt(excerpt.context.start).unwrap())
.collect::<Vec<_>>()
});
editor.update(cx, |editor, cx| {
editor.expand_excerpts(
excerpts.into_iter(),
2,
multi_buffer::ExpandExcerptDirection::UpAndDown,
cx,
);
});
}
#[gpui::test]
async fn test_basic_alignment(cx: &mut gpui::TestAppContext) {
use rope::Point;
use unindent::Unindent as _;
let (editor, mut cx) = init_test(cx, SoftWrap::EditorWidth, DiffViewStyle::Split).await;
let base_text = "
aaa
bbb
ccc
ddd
eee
fff
"
.unindent();
let current_text = "
aaa
ddd
eee
fff
"
.unindent();
let (buffer, diff) = buffer_with_diff(&base_text, &current_text, &mut cx);
editor.update(cx, |editor, cx| {
let path = PathKey::for_buffer(&buffer, cx);
editor.update_excerpts_for_path(
path,
buffer.clone(),
vec![Point::new(0, 0)..buffer.read(cx).max_point()],
0,
diff.clone(),
cx,
);
});
cx.run_until_parked();
assert_split_content(
&editor,
"
§ <no file>
§ -----
aaa
§ spacer
§ spacer
ddd
eee
fff"
.unindent(),
"
§ <no file>
§ -----
aaa
bbb
ccc
ddd
eee
fff"
.unindent(),
&mut cx,
);
buffer.update(cx, |buffer, cx| {
buffer.edit([(Point::new(3, 0)..Point::new(3, 3), "FFF")], None, cx);
});
cx.run_until_parked();
assert_split_content(
&editor,
"
§ <no file>
§ -----
aaa
§ spacer
§ spacer
ddd
eee
FFF"
.unindent(),
"
§ <no file>
§ -----
aaa
bbb
ccc
ddd
eee
fff"
.unindent(),
&mut cx,
);
let buffer_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot());
diff.update(cx, |diff, cx| {
diff.recalculate_diff_sync(&buffer_snapshot, cx);
});
cx.run_until_parked();
assert_split_content(
&editor,
"
§ <no file>
§ -----
aaa
§ spacer
§ spacer
ddd
eee
FFF"
.unindent(),
"
§ <no file>
§ -----
aaa
bbb
ccc
ddd
eee
fff"
.unindent(),
&mut cx,
);
}
#[gpui::test]
async fn test_deleting_unmodified_lines(cx: &mut gpui::TestAppContext) {
use rope::Point;
use unindent::Unindent as _;
let (editor, mut cx) = init_test(cx, SoftWrap::EditorWidth, DiffViewStyle::Split).await;
let base_text1 = "
aaa
bbb
ccc
ddd
eee"
.unindent();
let base_text2 = "
fff
ggg
hhh
iii
jjj"
.unindent();
let (buffer1, diff1) = buffer_with_diff(&base_text1, &base_text1, &mut cx);
let (buffer2, diff2) = buffer_with_diff(&base_text2, &base_text2, &mut cx);
editor.update(cx, |editor, cx| {
let path1 = PathKey::for_buffer(&buffer1, cx);
editor.update_excerpts_for_path(
path1,
buffer1.clone(),
vec![Point::new(0, 0)..buffer1.read(cx).max_point()],
0,
diff1.clone(),
cx,
);
let path2 = PathKey::for_buffer(&buffer2, cx);
editor.update_excerpts_for_path(
path2,
buffer2.clone(),
vec![Point::new(0, 0)..buffer2.read(cx).max_point()],
1,
diff2.clone(),
cx,
);
});
cx.run_until_parked();
buffer1.update(cx, |buffer, cx| {
buffer.edit(
[
(Point::new(0, 0)..Point::new(1, 0), ""),
(Point::new(3, 0)..Point::new(4, 0), ""),
],
None,
cx,
);
});
buffer2.update(cx, |buffer, cx| {
buffer.edit(
[
(Point::new(0, 0)..Point::new(1, 0), ""),
(Point::new(3, 0)..Point::new(4, 0), ""),
],
None,
cx,
);
});
cx.run_until_parked();
assert_split_content(
&editor,
"
§ <no file>
§ -----
§ spacer
bbb
ccc
§ spacer
eee
§ <no file>
§ -----
§ spacer
ggg
hhh
§ spacer
jjj"
.unindent(),
"
§ <no file>
§ -----
aaa
bbb
ccc
ddd
eee
§ <no file>
§ -----
fff
ggg
hhh
iii
jjj"
.unindent(),
&mut cx,
);
let buffer1_snapshot = buffer1.read_with(cx, |buffer, _| buffer.text_snapshot());
diff1.update(cx, |diff, cx| {
diff.recalculate_diff_sync(&buffer1_snapshot, cx);
});
let buffer2_snapshot = buffer2.read_with(cx, |buffer, _| buffer.text_snapshot());
diff2.update(cx, |diff, cx| {
diff.recalculate_diff_sync(&buffer2_snapshot, cx);
});
cx.run_until_parked();
assert_split_content(
&editor,
"
§ <no file>
§ -----
§ spacer
bbb
ccc
§ spacer
eee
§ <no file>
§ -----
§ spacer
ggg
hhh
§ spacer
jjj"
.unindent(),
"
§ <no file>
§ -----
aaa
bbb
ccc
ddd
eee
§ <no file>
§ -----
fff
ggg
hhh
iii
jjj"
.unindent(),
&mut cx,
);
}
#[gpui::test]
async fn test_deleting_added_line(cx: &mut gpui::TestAppContext) {
use rope::Point;
use unindent::Unindent as _;
let (editor, mut cx) = init_test(cx, SoftWrap::EditorWidth, DiffViewStyle::Split).await;
let base_text = "
aaa
bbb
ccc
ddd
"
.unindent();
let current_text = "
aaa
NEW1
NEW2
ccc
ddd
"
.unindent();
let (buffer, diff) = buffer_with_diff(&base_text, &current_text, &mut cx);
editor.update(cx, |editor, cx| {
let path = PathKey::for_buffer(&buffer, cx);
editor.update_excerpts_for_path(
path,
buffer.clone(),
vec![Point::new(0, 0)..buffer.read(cx).max_point()],
0,
diff.clone(),
cx,
);
});
cx.run_until_parked();
assert_split_content(
&editor,
"
§ <no file>
§ -----
aaa
NEW1
NEW2
ccc
ddd"
.unindent(),
"
§ <no file>
§ -----
aaa
bbb
§ spacer
ccc
ddd"
.unindent(),
&mut cx,
);
buffer.update(cx, |buffer, cx| {
buffer.edit([(Point::new(2, 0)..Point::new(3, 0), "")], None, cx);
});
cx.run_until_parked();
assert_split_content(
&editor,
"
§ <no file>
§ -----
aaa
NEW1
ccc
ddd"
.unindent(),
"
§ <no file>
§ -----
aaa
bbb
ccc
ddd"
.unindent(),
&mut cx,
);
let buffer_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot());
diff.update(cx, |diff, cx| {
diff.recalculate_diff_sync(&buffer_snapshot, cx);
});
cx.run_until_parked();
assert_split_content(
&editor,
"
§ <no file>
§ -----
aaa
NEW1
ccc
ddd"
.unindent(),
"
§ <no file>
§ -----
aaa
bbb
ccc
ddd"
.unindent(),
&mut cx,
);
}
#[gpui::test]
async fn test_inserting_consecutive_blank_line(cx: &mut gpui::TestAppContext) {
use rope::Point;
use unindent::Unindent as _;
let (editor, mut cx) = init_test(cx, SoftWrap::EditorWidth, DiffViewStyle::Split).await;
let base_text = "
aaa
bbb
ccc
ddd
"
.unindent();
let current_text = "
aaa
bbb
CCC
ddd
"
.unindent();
let (buffer, diff) = buffer_with_diff(&base_text, &current_text, &mut cx);
editor.update(cx, |editor, cx| {
let path = PathKey::for_buffer(&buffer, cx);
editor.update_excerpts_for_path(
path,
buffer.clone(),
vec![Point::new(0, 0)..buffer.read(cx).max_point()],
0,
diff.clone(),
cx,
);
});
cx.run_until_parked();
buffer.update(cx, |buffer, cx| {
buffer.edit([(Point::new(1, 3)..Point::new(1, 3), "\n")], None, cx);
});
cx.run_until_parked();
assert_split_content(
&editor,
"
§ <no file>
§ -----
aaa
bbb
CCC
ddd"
.unindent(),
"
§ <no file>
§ -----
aaa
bbb
§ spacer
ccc
ddd"
.unindent(),
&mut cx,
);
let buffer_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot());
diff.update(cx, |diff, cx| {
diff.recalculate_diff_sync(&buffer_snapshot, cx);
});
cx.run_until_parked();
assert_split_content(
&editor,
"
§ <no file>
§ -----
aaa
bbb
CCC
ddd"
.unindent(),
"
§ <no file>
§ -----
aaa
bbb
ccc
§ spacer
ddd"
.unindent(),
&mut cx,
);
}
#[gpui::test]
async fn test_reverting_deletion_hunk(cx: &mut gpui::TestAppContext) {
use git::Restore;
use rope::Point;
use unindent::Unindent as _;
let (editor, mut cx) = init_test(cx, SoftWrap::EditorWidth, DiffViewStyle::Split).await;
let base_text = "
aaa
bbb
ccc
ddd
eee
"
.unindent();
let current_text = "
aaa
ddd
eee
"
.unindent();
let (buffer, diff) = buffer_with_diff(&base_text, &current_text, &mut cx);
editor.update(cx, |editor, cx| {
let path = PathKey::for_buffer(&buffer, cx);
editor.update_excerpts_for_path(
path,
buffer.clone(),
vec![Point::new(0, 0)..buffer.read(cx).max_point()],
0,
diff.clone(),
cx,
);
});
cx.run_until_parked();
assert_split_content(
&editor,
"
§ <no file>
§ -----
aaa
§ spacer
§ spacer
ddd
eee"
.unindent(),
"
§ <no file>
§ -----
aaa
bbb
ccc
ddd
eee"
.unindent(),
&mut cx,
);
let rhs_editor = editor.update(cx, |editor, _cx| editor.rhs_editor.clone());
cx.update_window_entity(&rhs_editor, |editor, window, cx| {
editor.change_selections(crate::SelectionEffects::no_scroll(), window, cx, |s| {
s.select_ranges([Point::new(1, 0)..Point::new(1, 0)]);
});
editor.git_restore(&Restore, window, cx);
});
cx.run_until_parked();
assert_split_content(
&editor,
"
§ <no file>
§ -----
aaa
bbb
ccc
ddd
eee"
.unindent(),
"
§ <no file>
§ -----
aaa
bbb
ccc
ddd
eee"
.unindent(),
&mut cx,
);
let buffer_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot());
diff.update(cx, |diff, cx| {
diff.recalculate_diff_sync(&buffer_snapshot, cx);
});
cx.run_until_parked();
assert_split_content(
&editor,
"
§ <no file>
§ -----
aaa
bbb
ccc
ddd
eee"
.unindent(),
"
§ <no file>
§ -----
aaa
bbb
ccc
ddd
eee"
.unindent(),
&mut cx,
);
}
#[gpui::test]
async fn test_deleting_added_lines(cx: &mut gpui::TestAppContext) {
use rope::Point;
use unindent::Unindent as _;
let (editor, mut cx) = init_test(cx, SoftWrap::EditorWidth, DiffViewStyle::Split).await;
let base_text = "
aaa
old1
old2
old3
old4
zzz
"
.unindent();
let current_text = "
aaa
new1
new2
new3
new4
zzz
"
.unindent();
let (buffer, diff) = buffer_with_diff(&base_text, &current_text, &mut cx);
editor.update(cx, |editor, cx| {
let path = PathKey::for_buffer(&buffer, cx);
editor.update_excerpts_for_path(
path,
buffer.clone(),
vec![Point::new(0, 0)..buffer.read(cx).max_point()],
0,
diff.clone(),
cx,
);
});
cx.run_until_parked();
buffer.update(cx, |buffer, cx| {
buffer.edit(
[
(Point::new(2, 0)..Point::new(3, 0), ""),
(Point::new(4, 0)..Point::new(5, 0), ""),
],
None,
cx,
);
});
cx.run_until_parked();
assert_split_content(
&editor,
"
§ <no file>
§ -----
aaa
new1
new3
§ spacer
§ spacer
zzz"
.unindent(),
"
§ <no file>
§ -----
aaa
old1
old2
old3
old4
zzz"
.unindent(),
&mut cx,
);
let buffer_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot());
diff.update(cx, |diff, cx| {
diff.recalculate_diff_sync(&buffer_snapshot, cx);
});
cx.run_until_parked();
assert_split_content(
&editor,
"
§ <no file>
§ -----
aaa
new1
new3
§ spacer
§ spacer
zzz"
.unindent(),
"
§ <no file>
§ -----
aaa
old1
old2
old3
old4
zzz"
.unindent(),
&mut cx,
);
}
#[gpui::test]
async fn test_soft_wrap_at_end_of_excerpt(cx: &mut gpui::TestAppContext) {
use rope::Point;
use unindent::Unindent as _;
let (editor, mut cx) = init_test(cx, SoftWrap::EditorWidth, DiffViewStyle::Split).await;
let text = "aaaa bbbb cccc dddd eeee ffff";
let (buffer1, diff1) = buffer_with_diff(text, text, &mut cx);
let (buffer2, diff2) = buffer_with_diff(text, text, &mut cx);
editor.update(cx, |editor, cx| {
let end = Point::new(0, text.len() as u32);
let path1 = PathKey::for_buffer(&buffer1, cx);
editor.update_excerpts_for_path(
path1,
buffer1.clone(),
vec![Point::new(0, 0)..end],
0,
diff1.clone(),
cx,
);
let path2 = PathKey::for_buffer(&buffer2, cx);
editor.update_excerpts_for_path(
path2,
buffer2.clone(),
vec![Point::new(0, 0)..end],
0,
diff2.clone(),
cx,
);
});
cx.run_until_parked();
assert_split_content_with_widths(
&editor,
px(200.0),
px(400.0),
"
§ <no file>
§ -----
aaaa bbbb\x20
cccc dddd\x20
eeee ffff
§ <no file>
§ -----
aaaa bbbb\x20
cccc dddd\x20
eeee ffff"
.unindent(),
"
§ <no file>
§ -----
aaaa bbbb cccc dddd eeee ffff
§ spacer
§ spacer
§ <no file>
§ -----
aaaa bbbb cccc dddd eeee ffff
§ spacer
§ spacer"
.unindent(),
&mut cx,
);
}
#[gpui::test]
async fn test_soft_wrap_before_modification_hunk(cx: &mut gpui::TestAppContext) {
use rope::Point;
use unindent::Unindent as _;
let (editor, mut cx) = init_test(cx, SoftWrap::EditorWidth, DiffViewStyle::Split).await;
let base_text = "
aaaa bbbb cccc dddd eeee ffff
old line one
old line two
"
.unindent();
let current_text = "
aaaa bbbb cccc dddd eeee ffff
new line
"
.unindent();
let (buffer, diff) = buffer_with_diff(&base_text, &current_text, &mut cx);
editor.update(cx, |editor, cx| {
let path = PathKey::for_buffer(&buffer, cx);
editor.update_excerpts_for_path(
path,
buffer.clone(),
vec![Point::new(0, 0)..buffer.read(cx).max_point()],
0,
diff.clone(),
cx,
);
});
cx.run_until_parked();
assert_split_content_with_widths(
&editor,
px(200.0),
px(400.0),
"
§ <no file>
§ -----
aaaa bbbb\x20
cccc dddd\x20
eeee ffff
new line
§ spacer"
.unindent(),
"
§ <no file>
§ -----
aaaa bbbb cccc dddd eeee ffff
§ spacer
§ spacer
old line one
old line two"
.unindent(),
&mut cx,
);
}
#[gpui::test]
async fn test_soft_wrap_before_deletion_hunk(cx: &mut gpui::TestAppContext) {
use rope::Point;
use unindent::Unindent as _;
let (editor, mut cx) = init_test(cx, SoftWrap::EditorWidth, DiffViewStyle::Split).await;
let base_text = "
aaaa bbbb cccc dddd eeee ffff
deleted line one
deleted line two
after
"
.unindent();
let current_text = "
aaaa bbbb cccc dddd eeee ffff
after
"
.unindent();
let (buffer, diff) = buffer_with_diff(&base_text, &current_text, &mut cx);
editor.update(cx, |editor, cx| {
let path = PathKey::for_buffer(&buffer, cx);
editor.update_excerpts_for_path(
path,
buffer.clone(),
vec![Point::new(0, 0)..buffer.read(cx).max_point()],
0,
diff.clone(),
cx,
);
});
cx.run_until_parked();
assert_split_content_with_widths(
&editor,
px(400.0),
px(200.0),
"
§ <no file>
§ -----
aaaa bbbb cccc dddd eeee ffff
§ spacer
§ spacer
§ spacer
§ spacer
§ spacer
§ spacer
after"
.unindent(),
"
§ <no file>
§ -----
aaaa bbbb\x20
cccc dddd\x20
eeee ffff
deleted line\x20
one
deleted line\x20
two
after"
.unindent(),
&mut cx,
);
}
#[gpui::test]
async fn test_soft_wrap_spacer_after_editing_second_line(cx: &mut gpui::TestAppContext) {
use rope::Point;
use unindent::Unindent as _;
let (editor, mut cx) = init_test(cx, SoftWrap::EditorWidth, DiffViewStyle::Split).await;
let text = "
aaaa bbbb cccc dddd eeee ffff
short
"
.unindent();
let (buffer, diff) = buffer_with_diff(&text, &text, &mut cx);
editor.update(cx, |editor, cx| {
let path = PathKey::for_buffer(&buffer, cx);
editor.update_excerpts_for_path(
path,
buffer.clone(),
vec![Point::new(0, 0)..buffer.read(cx).max_point()],
0,
diff.clone(),
cx,
);
});
cx.run_until_parked();
assert_split_content_with_widths(
&editor,
px(400.0),
px(200.0),
"
§ <no file>
§ -----
aaaa bbbb cccc dddd eeee ffff
§ spacer
§ spacer
short"
.unindent(),
"
§ <no file>
§ -----
aaaa bbbb\x20
cccc dddd\x20
eeee ffff
short"
.unindent(),
&mut cx,
);
buffer.update(cx, |buffer, cx| {
buffer.edit([(Point::new(1, 0)..Point::new(1, 5), "modified")], None, cx);
});
cx.run_until_parked();
assert_split_content_with_widths(
&editor,
px(400.0),
px(200.0),
"
§ <no file>
§ -----
aaaa bbbb cccc dddd eeee ffff
§ spacer
§ spacer
modified"
.unindent(),
"
§ <no file>
§ -----
aaaa bbbb\x20
cccc dddd\x20
eeee ffff
short"
.unindent(),
&mut cx,
);
let buffer_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot());
diff.update(cx, |diff, cx| {
diff.recalculate_diff_sync(&buffer_snapshot, cx);
});
cx.run_until_parked();
assert_split_content_with_widths(
&editor,
px(400.0),
px(200.0),
"
§ <no file>
§ -----
aaaa bbbb cccc dddd eeee ffff
§ spacer
§ spacer
modified"
.unindent(),
"
§ <no file>
§ -----
aaaa bbbb\x20
cccc dddd\x20
eeee ffff
short"
.unindent(),
&mut cx,
);
}
#[gpui::test]
async fn test_no_base_text(cx: &mut gpui::TestAppContext) {
use rope::Point;
use unindent::Unindent as _;
let (editor, mut cx) = init_test(cx, SoftWrap::EditorWidth, DiffViewStyle::Split).await;
let (buffer1, diff1) = buffer_with_diff("xxx\nyyy", "xxx\nyyy", &mut cx);
let current_text = "
aaa
bbb
ccc
"
.unindent();
let buffer2 = cx.new(|cx| Buffer::local(current_text.to_string(), cx));
let diff2 = cx.new(|cx| BufferDiff::new(&buffer2.read(cx).text_snapshot(), cx));
editor.update(cx, |editor, cx| {
let path1 = PathKey::for_buffer(&buffer1, cx);
editor.update_excerpts_for_path(
path1,
buffer1.clone(),
vec![Point::new(0, 0)..buffer1.read(cx).max_point()],
0,
diff1.clone(),
cx,
);
let path2 = PathKey::for_buffer(&buffer2, cx);
editor.update_excerpts_for_path(
path2,
buffer2.clone(),
vec![Point::new(0, 0)..buffer2.read(cx).max_point()],
1,
diff2.clone(),
cx,
);
});
cx.run_until_parked();
assert_split_content(
&editor,
"
§ <no file>
§ -----
xxx
yyy
§ <no file>
§ -----
aaa
bbb
ccc"
.unindent(),
"
§ <no file>
§ -----
xxx
yyy
§ <no file>
§ -----
§ spacer
§ spacer
§ spacer"
.unindent(),
&mut cx,
);
buffer1.update(cx, |buffer, cx| {
buffer.edit([(Point::new(0, 3)..Point::new(0, 3), "z")], None, cx);
});
cx.run_until_parked();
assert_split_content(
&editor,
"
§ <no file>
§ -----
xxxz
yyy
§ <no file>
§ -----
aaa
bbb
ccc"
.unindent(),
"
§ <no file>
§ -----
xxx
yyy
§ <no file>
§ -----
§ spacer
§ spacer
§ spacer"
.unindent(),
&mut cx,
);
}
#[gpui::test]
async fn test_deleting_char_in_added_line(cx: &mut gpui::TestAppContext) {
use rope::Point;
use unindent::Unindent as _;
let (editor, mut cx) = init_test(cx, SoftWrap::EditorWidth, DiffViewStyle::Split).await;
let base_text = "
aaa
bbb
ccc
"
.unindent();
let current_text = "
NEW1
NEW2
ccc
"
.unindent();
let (buffer, diff) = buffer_with_diff(&base_text, &current_text, &mut cx);
editor.update(cx, |editor, cx| {
let path = PathKey::for_buffer(&buffer, cx);
editor.update_excerpts_for_path(
path,
buffer.clone(),
vec![Point::new(0, 0)..buffer.read(cx).max_point()],
0,
diff.clone(),
cx,
);
});
cx.run_until_parked();
assert_split_content(
&editor,
"
§ <no file>
§ -----
NEW1
NEW2
ccc"
.unindent(),
"
§ <no file>
§ -----
aaa
bbb
ccc"
.unindent(),
&mut cx,
);
buffer.update(cx, |buffer, cx| {
buffer.edit([(Point::new(1, 3)..Point::new(1, 4), "")], None, cx);
});
cx.run_until_parked();
assert_split_content(
&editor,
"
§ <no file>
§ -----
NEW1
NEW
ccc"
.unindent(),
"
§ <no file>
§ -----
aaa
bbb
ccc"
.unindent(),
&mut cx,
);
}
#[gpui::test]
async fn test_soft_wrap_spacer_before_added_line(cx: &mut gpui::TestAppContext) {
use rope::Point;
use unindent::Unindent as _;
let (editor, mut cx) = init_test(cx, SoftWrap::EditorWidth, DiffViewStyle::Split).await;
let base_text = "aaaa bbbb cccc dddd eeee ffff\n";
let current_text = "
aaaa bbbb cccc dddd eeee ffff
added line
"
.unindent();
let (buffer, diff) = buffer_with_diff(&base_text, &current_text, &mut cx);
editor.update(cx, |editor, cx| {
let path = PathKey::for_buffer(&buffer, cx);
editor.update_excerpts_for_path(
path,
buffer.clone(),
vec![Point::new(0, 0)..buffer.read(cx).max_point()],
0,
diff.clone(),
cx,
);
});
cx.run_until_parked();
assert_split_content_with_widths(
&editor,
px(400.0),
px(200.0),
"
§ <no file>
§ -----
aaaa bbbb cccc dddd eeee ffff
§ spacer
§ spacer
added line"
.unindent(),
"
§ <no file>
§ -----
aaaa bbbb\x20
cccc dddd\x20
eeee ffff
§ spacer"
.unindent(),
&mut cx,
);
assert_split_content_with_widths(
&editor,
px(200.0),
px(400.0),
"
§ <no file>
§ -----
aaaa bbbb\x20
cccc dddd\x20
eeee ffff
added line"
.unindent(),
"
§ <no file>
§ -----
aaaa bbbb cccc dddd eeee ffff
§ spacer
§ spacer
§ spacer"
.unindent(),
&mut cx,
);
}
#[gpui::test]
#[ignore]
async fn test_joining_added_line_with_unmodified_line(cx: &mut gpui::TestAppContext) {
use rope::Point;
use unindent::Unindent as _;
let (editor, mut cx) = init_test(cx, SoftWrap::EditorWidth, DiffViewStyle::Split).await;
let base_text = "
aaa
bbb
ccc
ddd
eee
"
.unindent();
let current_text = "
aaa
NEW
eee
"
.unindent();
let (buffer, diff) = buffer_with_diff(&base_text, &current_text, &mut cx);
editor.update(cx, |editor, cx| {
let path = PathKey::for_buffer(&buffer, cx);
editor.update_excerpts_for_path(
path,
buffer.clone(),
vec![Point::new(0, 0)..buffer.read(cx).max_point()],
0,
diff.clone(),
cx,
);
});
cx.run_until_parked();
assert_split_content(
&editor,
"
§ <no file>
§ -----
aaa
NEW
§ spacer
§ spacer
eee"
.unindent(),
"
§ <no file>
§ -----
aaa
bbb
ccc
ddd
eee"
.unindent(),
&mut cx,
);
buffer.update(cx, |buffer, cx| {
buffer.edit([(Point::new(1, 3)..Point::new(2, 0), "")], None, cx);
});
cx.run_until_parked();
assert_split_content(
&editor,
"
§ <no file>
§ -----
aaa
§ spacer
§ spacer
§ spacer
NEWeee"
.unindent(),
"
§ <no file>
§ -----
aaa
bbb
ccc
ddd
eee"
.unindent(),
&mut cx,
);
let buffer_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot());
diff.update(cx, |diff, cx| {
diff.recalculate_diff_sync(&buffer_snapshot, cx);
});
cx.run_until_parked();
assert_split_content(
&editor,
"
§ <no file>
§ -----
aaa
NEWeee
§ spacer
§ spacer
§ spacer"
.unindent(),
"
§ <no file>
§ -----
aaa
bbb
ccc
ddd
eee"
.unindent(),
&mut cx,
);
}
#[gpui::test]
async fn test_added_file_at_end(cx: &mut gpui::TestAppContext) {
use rope::Point;
use unindent::Unindent as _;
let (editor, mut cx) = init_test(cx, SoftWrap::EditorWidth, DiffViewStyle::Split).await;
let base_text = "";
let current_text = "
aaaa bbbb cccc dddd eeee ffff
bbb
ccc
"
.unindent();
let (buffer, diff) = buffer_with_diff(base_text, &current_text, &mut cx);
editor.update(cx, |editor, cx| {
let path = PathKey::for_buffer(&buffer, cx);
editor.update_excerpts_for_path(
path,
buffer.clone(),
vec![Point::new(0, 0)..buffer.read(cx).max_point()],
0,
diff.clone(),
cx,
);
});
cx.run_until_parked();
assert_split_content(
&editor,
"
§ <no file>
§ -----
aaaa bbbb cccc dddd eeee ffff
bbb
ccc"
.unindent(),
"
§ <no file>
§ -----
§ spacer
§ spacer
§ spacer"
.unindent(),
&mut cx,
);
assert_split_content_with_widths(
&editor,
px(200.0),
px(200.0),
"
§ <no file>
§ -----
aaaa bbbb\x20
cccc dddd\x20
eeee ffff
bbb
ccc"
.unindent(),
"
§ <no file>
§ -----
§ spacer
§ spacer
§ spacer
§ spacer
§ spacer"
.unindent(),
&mut cx,
);
}
#[gpui::test]
async fn test_adding_line_to_addition_hunk(cx: &mut gpui::TestAppContext) {
use rope::Point;
use unindent::Unindent as _;
let (editor, mut cx) = init_test(cx, SoftWrap::EditorWidth, DiffViewStyle::Split).await;
let base_text = "
aaa
bbb
ccc
"
.unindent();
let current_text = "
aaa
bbb
xxx
yyy
ccc
"
.unindent();
let (buffer, diff) = buffer_with_diff(&base_text, &current_text, &mut cx);
editor.update(cx, |editor, cx| {
let path = PathKey::for_buffer(&buffer, cx);
editor.update_excerpts_for_path(
path,
buffer.clone(),
vec![Point::new(0, 0)..buffer.read(cx).max_point()],
0,
diff.clone(),
cx,
);
});
cx.run_until_parked();
assert_split_content(
&editor,
"
§ <no file>
§ -----
aaa
bbb
xxx
yyy
ccc"
.unindent(),
"
§ <no file>
§ -----
aaa
bbb
§ spacer
§ spacer
ccc"
.unindent(),
&mut cx,
);
buffer.update(cx, |buffer, cx| {
buffer.edit([(Point::new(3, 3)..Point::new(3, 3), "\nzzz")], None, cx);
});
cx.run_until_parked();
assert_split_content(
&editor,
"
§ <no file>
§ -----
aaa
bbb
xxx
yyy
zzz
ccc"
.unindent(),
"
§ <no file>
§ -----
aaa
bbb
§ spacer
§ spacer
§ spacer
ccc"
.unindent(),
&mut cx,
);
}
#[gpui::test]
async fn test_scrolling(cx: &mut gpui::TestAppContext) {
use crate::test::editor_content_with_blocks_and_size;
use gpui::size;
use rope::Point;
let (editor, mut cx) = init_test(cx, SoftWrap::None, DiffViewStyle::Split).await;
let long_line = "x".repeat(200);
let mut lines: Vec<String> = (0..50).map(|i| format!("line {i}")).collect();
lines[25] = long_line;
let content = lines.join("\n");
let (buffer, diff) = buffer_with_diff(&content, &content, &mut cx);
editor.update(cx, |editor, cx| {
let path = PathKey::for_buffer(&buffer, cx);
editor.update_excerpts_for_path(
path,
buffer.clone(),
vec![Point::new(0, 0)..buffer.read(cx).max_point()],
0,
diff.clone(),
cx,
);
});
cx.run_until_parked();
let (rhs_editor, lhs_editor) = editor.update(cx, |editor, _cx| {
let lhs = editor.lhs.as_ref().expect("should have lhs editor");
(editor.rhs_editor.clone(), lhs.editor.clone())
});
rhs_editor.update_in(cx, |e, window, cx| {
e.set_scroll_position(gpui::Point::new(0., 10.), window, cx);
});
let rhs_pos =
rhs_editor.update_in(cx, |e, window, cx| e.snapshot(window, cx).scroll_position());
let lhs_pos =
lhs_editor.update_in(cx, |e, window, cx| e.snapshot(window, cx).scroll_position());
assert_eq!(rhs_pos.y, 10., "RHS should be scrolled to row 10");
assert_eq!(
lhs_pos.y, rhs_pos.y,
"LHS should have same scroll position as RHS after set_scroll_position"
);
let draw_size = size(px(300.), px(300.));
rhs_editor.update_in(cx, |e, window, cx| {
e.change_selections(Some(crate::Autoscroll::fit()).into(), window, cx, |s| {
s.select_ranges([Point::new(25, 150)..Point::new(25, 150)]);
});
});
let _ = editor_content_with_blocks_and_size(&rhs_editor, draw_size, &mut cx);
cx.run_until_parked();
let _ = editor_content_with_blocks_and_size(&lhs_editor, draw_size, &mut cx);
cx.run_until_parked();
let rhs_pos =
rhs_editor.update_in(cx, |e, window, cx| e.snapshot(window, cx).scroll_position());
let lhs_pos =
lhs_editor.update_in(cx, |e, window, cx| e.snapshot(window, cx).scroll_position());
assert!(
rhs_pos.y > 0.,
"RHS should have scrolled vertically to show cursor at row 25"
);
assert!(
rhs_pos.x > 0.,
"RHS should have scrolled horizontally to show cursor at column 150"
);
assert_eq!(
lhs_pos.y, rhs_pos.y,
"LHS should have same vertical scroll position as RHS after autoscroll"
);
assert_eq!(
lhs_pos.x, rhs_pos.x,
"LHS should have same horizontal scroll position as RHS after autoscroll"
)
}
#[gpui::test]
async fn test_edit_line_before_soft_wrapped_line_preceding_hunk(cx: &mut gpui::TestAppContext) {
use rope::Point;
use unindent::Unindent as _;
let (editor, mut cx) = init_test(cx, SoftWrap::EditorWidth, DiffViewStyle::Split).await;
let base_text = "
first line
aaaa bbbb cccc dddd eeee ffff
original
"
.unindent();
let current_text = "
first line
aaaa bbbb cccc dddd eeee ffff
modified
"
.unindent();
let (buffer, diff) = buffer_with_diff(&base_text, &current_text, &mut cx);
editor.update(cx, |editor, cx| {
let path = PathKey::for_buffer(&buffer, cx);
editor.update_excerpts_for_path(
path,
buffer.clone(),
vec![Point::new(0, 0)..buffer.read(cx).max_point()],
0,
diff.clone(),
cx,
);
});
cx.run_until_parked();
assert_split_content_with_widths(
&editor,
px(400.0),
px(200.0),
"
§ <no file>
§ -----
first line
aaaa bbbb cccc dddd eeee ffff
§ spacer
§ spacer
modified"
.unindent(),
"
§ <no file>
§ -----
first line
aaaa bbbb\x20
cccc dddd\x20
eeee ffff
original"
.unindent(),
&mut cx,
);
buffer.update(cx, |buffer, cx| {
buffer.edit(
[(Point::new(0, 0)..Point::new(0, 10), "edited first")],
None,
cx,
);
});
cx.run_until_parked();
assert_split_content_with_widths(
&editor,
px(400.0),
px(200.0),
"
§ <no file>
§ -----
edited first
aaaa bbbb cccc dddd eeee ffff
§ spacer
§ spacer
modified"
.unindent(),
"
§ <no file>
§ -----
first line
aaaa bbbb\x20
cccc dddd\x20
eeee ffff
original"
.unindent(),
&mut cx,
);
let buffer_snapshot = buffer.read_with(cx, |buffer, _| buffer.text_snapshot());
diff.update(cx, |diff, cx| {
diff.recalculate_diff_sync(&buffer_snapshot, cx);
});
cx.run_until_parked();
assert_split_content_with_widths(
&editor,
px(400.0),
px(200.0),
"
§ <no file>
§ -----
edited first
aaaa bbbb cccc dddd eeee ffff
§ spacer
§ spacer
modified"
.unindent(),
"
§ <no file>
§ -----
first line
aaaa bbbb\x20
cccc dddd\x20
eeee ffff
original"
.unindent(),
&mut cx,
);
}
#[gpui::test]
async fn test_custom_block_sync_between_split_views(cx: &mut gpui::TestAppContext) {
use rope::Point;
use unindent::Unindent as _;
let (editor, mut cx) = init_test(cx, SoftWrap::None, DiffViewStyle::Split).await;
let base_text = "
bbb
ccc
"
.unindent();
let current_text = "
aaa
bbb
ccc
"
.unindent();
let (buffer, diff) = buffer_with_diff(&base_text, &current_text, &mut cx);
editor.update(cx, |editor, cx| {
let path = PathKey::for_buffer(&buffer, cx);
editor.update_excerpts_for_path(
path,
buffer.clone(),
vec![Point::new(0, 0)..buffer.read(cx).max_point()],
0,
diff.clone(),
cx,
);
});
cx.run_until_parked();
assert_split_content(
&editor,
"
§ <no file>
§ -----
aaa
bbb
ccc"
.unindent(),
"
§ <no file>
§ -----
§ spacer
bbb
ccc"
.unindent(),
&mut cx,
);
let block_ids = editor.update(cx, |splittable_editor, cx| {
splittable_editor.rhs_editor.update(cx, |rhs_editor, cx| {
let snapshot = rhs_editor.buffer().read(cx).snapshot(cx);
let anchor = snapshot.anchor_before(Point::new(2, 0));
rhs_editor.insert_blocks(
[BlockProperties {
placement: BlockPlacement::Above(anchor),
height: Some(1),
style: BlockStyle::Fixed,
render: Arc::new(|_| div().into_any()),
priority: 0,
}],
None,
cx,
)
})
});
let rhs_editor = editor.read_with(cx, |editor, _| editor.rhs_editor.clone());
let lhs_editor =
editor.read_with(cx, |editor, _| editor.lhs.as_ref().unwrap().editor.clone());
cx.update(|_, cx| {
set_block_content_for_tests(&rhs_editor, block_ids[0], cx, |_| {
"custom block".to_string()
});
});
let lhs_block_id = lhs_editor.read_with(cx, |lhs_editor, cx| {
let display_map = lhs_editor.display_map.read(cx);
let companion = display_map.companion().unwrap().read(cx);
let mapping = companion
.custom_block_to_balancing_block(rhs_editor.read(cx).display_map.entity_id());
*mapping.borrow().get(&block_ids[0]).unwrap()
});
cx.update(|_, cx| {
set_block_content_for_tests(&lhs_editor, lhs_block_id, cx, |_| {
"custom block".to_string()
});
});
cx.run_until_parked();
assert_split_content(
&editor,
"
§ <no file>
§ -----
aaa
bbb
§ custom block
ccc"
.unindent(),
"
§ <no file>
§ -----
§ spacer
bbb
§ custom block
ccc"
.unindent(),
&mut cx,
);
editor.update(cx, |splittable_editor, cx| {
splittable_editor.rhs_editor.update(cx, |rhs_editor, cx| {
rhs_editor.remove_blocks(HashSet::from_iter(block_ids), None, cx);
});
});
cx.run_until_parked();
assert_split_content(
&editor,
"
§ <no file>
§ -----
aaa
bbb
ccc"
.unindent(),
"
§ <no file>
§ -----
§ spacer
bbb
ccc"
.unindent(),
&mut cx,
);
}
#[gpui::test]
async fn test_custom_block_deletion_and_resplit_sync(cx: &mut gpui::TestAppContext) {
use rope::Point;
use unindent::Unindent as _;
let (editor, mut cx) = init_test(cx, SoftWrap::None, DiffViewStyle::Split).await;
let base_text = "
bbb
ccc
"
.unindent();
let current_text = "
aaa
bbb
ccc
"
.unindent();
let (buffer, diff) = buffer_with_diff(&base_text, &current_text, &mut cx);
editor.update(cx, |editor, cx| {
let path = PathKey::for_buffer(&buffer, cx);
editor.update_excerpts_for_path(
path,
buffer.clone(),
vec![Point::new(0, 0)..buffer.read(cx).max_point()],
0,
diff.clone(),
cx,
);
});
cx.run_until_parked();
assert_split_content(
&editor,
"
§ <no file>
§ -----
aaa
bbb
ccc"
.unindent(),
"
§ <no file>
§ -----
§ spacer
bbb
ccc"
.unindent(),
&mut cx,
);
let block_ids = editor.update(cx, |splittable_editor, cx| {
splittable_editor.rhs_editor.update(cx, |rhs_editor, cx| {
let snapshot = rhs_editor.buffer().read(cx).snapshot(cx);
let anchor1 = snapshot.anchor_before(Point::new(2, 0));
let anchor2 = snapshot.anchor_before(Point::new(3, 0));
rhs_editor.insert_blocks(
[
BlockProperties {
placement: BlockPlacement::Above(anchor1),
height: Some(1),
style: BlockStyle::Fixed,
render: Arc::new(|_| div().into_any()),
priority: 0,
},
BlockProperties {
placement: BlockPlacement::Above(anchor2),
height: Some(1),
style: BlockStyle::Fixed,
render: Arc::new(|_| div().into_any()),
priority: 0,
},
],
None,
cx,
)
})
});
let rhs_editor = editor.read_with(cx, |editor, _| editor.rhs_editor.clone());
let lhs_editor =
editor.read_with(cx, |editor, _| editor.lhs.as_ref().unwrap().editor.clone());
cx.update(|_, cx| {
set_block_content_for_tests(&rhs_editor, block_ids[0], cx, |_| {
"custom block 1".to_string()
});
set_block_content_for_tests(&rhs_editor, block_ids[1], cx, |_| {
"custom block 2".to_string()
});
});
let (lhs_block_id_1, lhs_block_id_2) = lhs_editor.read_with(cx, |lhs_editor, cx| {
let display_map = lhs_editor.display_map.read(cx);
let companion = display_map.companion().unwrap().read(cx);
let mapping = companion
.custom_block_to_balancing_block(rhs_editor.read(cx).display_map.entity_id());
(
*mapping.borrow().get(&block_ids[0]).unwrap(),
*mapping.borrow().get(&block_ids[1]).unwrap(),
)
});
cx.update(|_, cx| {
set_block_content_for_tests(&lhs_editor, lhs_block_id_1, cx, |_| {
"custom block 1".to_string()
});
set_block_content_for_tests(&lhs_editor, lhs_block_id_2, cx, |_| {
"custom block 2".to_string()
});
});
cx.run_until_parked();
assert_split_content(
&editor,
"
§ <no file>
§ -----
aaa
bbb
§ custom block 1
ccc
§ custom block 2"
.unindent(),
"
§ <no file>
§ -----
§ spacer
bbb
§ custom block 1
ccc
§ custom block 2"
.unindent(),
&mut cx,
);
editor.update(cx, |splittable_editor, cx| {
splittable_editor.rhs_editor.update(cx, |rhs_editor, cx| {
rhs_editor.remove_blocks(HashSet::from_iter([block_ids[0]]), None, cx);
});
});
cx.run_until_parked();
assert_split_content(
&editor,
"
§ <no file>
§ -----
aaa
bbb
ccc
§ custom block 2"
.unindent(),
"
§ <no file>
§ -----
§ spacer
bbb
ccc
§ custom block 2"
.unindent(),
&mut cx,
);
editor.update_in(cx, |splittable_editor, window, cx| {
splittable_editor.unsplit(window, cx);
});
cx.run_until_parked();
editor.update_in(cx, |splittable_editor, window, cx| {
splittable_editor.split(window, cx);
});
cx.run_until_parked();
let lhs_editor =
editor.read_with(cx, |editor, _| editor.lhs.as_ref().unwrap().editor.clone());
let lhs_block_id_2 = lhs_editor.read_with(cx, |lhs_editor, cx| {
let display_map = lhs_editor.display_map.read(cx);
let companion = display_map.companion().unwrap().read(cx);
let mapping = companion
.custom_block_to_balancing_block(rhs_editor.read(cx).display_map.entity_id());
*mapping.borrow().get(&block_ids[1]).unwrap()
});
cx.update(|_, cx| {
set_block_content_for_tests(&lhs_editor, lhs_block_id_2, cx, |_| {
"custom block 2".to_string()
});
});
cx.run_until_parked();
assert_split_content(
&editor,
"
§ <no file>
§ -----
aaa
bbb
ccc
§ custom block 2"
.unindent(),
"
§ <no file>
§ -----
§ spacer
bbb
ccc
§ custom block 2"
.unindent(),
&mut cx,
);
}
#[gpui::test]
async fn test_custom_block_sync_with_unsplit_start(cx: &mut gpui::TestAppContext) {
use rope::Point;
use unindent::Unindent as _;
let (editor, mut cx) = init_test(cx, SoftWrap::None, DiffViewStyle::Split).await;
let base_text = "
bbb
ccc
"
.unindent();
let current_text = "
aaa
bbb
ccc
"
.unindent();
let (buffer, diff) = buffer_with_diff(&base_text, &current_text, &mut cx);
editor.update(cx, |editor, cx| {
let path = PathKey::for_buffer(&buffer, cx);
editor.update_excerpts_for_path(
path,
buffer.clone(),
vec![Point::new(0, 0)..buffer.read(cx).max_point()],
0,
diff.clone(),
cx,
);
});
cx.run_until_parked();
editor.update_in(cx, |splittable_editor, window, cx| {
splittable_editor.unsplit(window, cx);
});
cx.run_until_parked();
let rhs_editor = editor.read_with(cx, |editor, _| editor.rhs_editor.clone());
let block_ids = editor.update(cx, |splittable_editor, cx| {
splittable_editor.rhs_editor.update(cx, |rhs_editor, cx| {
let snapshot = rhs_editor.buffer().read(cx).snapshot(cx);
let anchor1 = snapshot.anchor_before(Point::new(2, 0));
let anchor2 = snapshot.anchor_before(Point::new(3, 0));
rhs_editor.insert_blocks(
[
BlockProperties {
placement: BlockPlacement::Above(anchor1),
height: Some(1),
style: BlockStyle::Fixed,
render: Arc::new(|_| div().into_any()),
priority: 0,
},
BlockProperties {
placement: BlockPlacement::Above(anchor2),
height: Some(1),
style: BlockStyle::Fixed,
render: Arc::new(|_| div().into_any()),
priority: 0,
},
],
None,
cx,
)
})
});
cx.update(|_, cx| {
set_block_content_for_tests(&rhs_editor, block_ids[0], cx, |_| {
"custom block 1".to_string()
});
set_block_content_for_tests(&rhs_editor, block_ids[1], cx, |_| {
"custom block 2".to_string()
});
});
cx.run_until_parked();
let rhs_content = editor_content_with_blocks_and_width(&rhs_editor, px(3000.0), &mut cx);
assert_eq!(
rhs_content,
"
§ <no file>
§ -----
aaa
bbb
§ custom block 1
ccc
§ custom block 2"
.unindent(),
"rhs content before split"
);
editor.update_in(cx, |splittable_editor, window, cx| {
splittable_editor.split(window, cx);
});
cx.run_until_parked();
let lhs_editor =
editor.read_with(cx, |editor, _| editor.lhs.as_ref().unwrap().editor.clone());
let (lhs_block_id_1, lhs_block_id_2) = lhs_editor.read_with(cx, |lhs_editor, cx| {
let display_map = lhs_editor.display_map.read(cx);
let companion = display_map.companion().unwrap().read(cx);
let mapping = companion
.custom_block_to_balancing_block(rhs_editor.read(cx).display_map.entity_id());
(
*mapping.borrow().get(&block_ids[0]).unwrap(),
*mapping.borrow().get(&block_ids[1]).unwrap(),
)
});
cx.update(|_, cx| {
set_block_content_for_tests(&lhs_editor, lhs_block_id_1, cx, |_| {
"custom block 1".to_string()
});
set_block_content_for_tests(&lhs_editor, lhs_block_id_2, cx, |_| {
"custom block 2".to_string()
});
});
cx.run_until_parked();
assert_split_content(
&editor,
"
§ <no file>
§ -----
aaa
bbb
§ custom block 1
ccc
§ custom block 2"
.unindent(),
"
§ <no file>
§ -----
§ spacer
bbb
§ custom block 1
ccc
§ custom block 2"
.unindent(),
&mut cx,
);
editor.update(cx, |splittable_editor, cx| {
splittable_editor.rhs_editor.update(cx, |rhs_editor, cx| {
rhs_editor.remove_blocks(HashSet::from_iter([block_ids[0]]), None, cx);
});
});
cx.run_until_parked();
assert_split_content(
&editor,
"
§ <no file>
§ -----
aaa
bbb
ccc
§ custom block 2"
.unindent(),
"
§ <no file>
§ -----
§ spacer
bbb
ccc
§ custom block 2"
.unindent(),
&mut cx,
);
editor.update_in(cx, |splittable_editor, window, cx| {
splittable_editor.unsplit(window, cx);
});
cx.run_until_parked();
editor.update_in(cx, |splittable_editor, window, cx| {
splittable_editor.split(window, cx);
});
cx.run_until_parked();
let lhs_editor =
editor.read_with(cx, |editor, _| editor.lhs.as_ref().unwrap().editor.clone());
let lhs_block_id_2 = lhs_editor.read_with(cx, |lhs_editor, cx| {
let display_map = lhs_editor.display_map.read(cx);
let companion = display_map.companion().unwrap().read(cx);
let mapping = companion
.custom_block_to_balancing_block(rhs_editor.read(cx).display_map.entity_id());
*mapping.borrow().get(&block_ids[1]).unwrap()
});
cx.update(|_, cx| {
set_block_content_for_tests(&lhs_editor, lhs_block_id_2, cx, |_| {
"custom block 2".to_string()
});
});
cx.run_until_parked();
assert_split_content(
&editor,
"
§ <no file>
§ -----
aaa
bbb
ccc
§ custom block 2"
.unindent(),
"
§ <no file>
§ -----
§ spacer
bbb
ccc
§ custom block 2"
.unindent(),
&mut cx,
);
let new_block_ids = editor.update(cx, |splittable_editor, cx| {
splittable_editor.rhs_editor.update(cx, |rhs_editor, cx| {
let snapshot = rhs_editor.buffer().read(cx).snapshot(cx);
let anchor = snapshot.anchor_before(Point::new(2, 0));
rhs_editor.insert_blocks(
[BlockProperties {
placement: BlockPlacement::Above(anchor),
height: Some(1),
style: BlockStyle::Fixed,
render: Arc::new(|_| div().into_any()),
priority: 0,
}],
None,
cx,
)
})
});
cx.update(|_, cx| {
set_block_content_for_tests(&rhs_editor, new_block_ids[0], cx, |_| {
"custom block 3".to_string()
});
});
let lhs_block_id_3 = lhs_editor.read_with(cx, |lhs_editor, cx| {
let display_map = lhs_editor.display_map.read(cx);
let companion = display_map.companion().unwrap().read(cx);
let mapping = companion
.custom_block_to_balancing_block(rhs_editor.read(cx).display_map.entity_id());
*mapping.borrow().get(&new_block_ids[0]).unwrap()
});
cx.update(|_, cx| {
set_block_content_for_tests(&lhs_editor, lhs_block_id_3, cx, |_| {
"custom block 3".to_string()
});
});
cx.run_until_parked();
assert_split_content(
&editor,
"
§ <no file>
§ -----
aaa
bbb
§ custom block 3
ccc
§ custom block 2"
.unindent(),
"
§ <no file>
§ -----
§ spacer
bbb
§ custom block 3
ccc
§ custom block 2"
.unindent(),
&mut cx,
);
editor.update(cx, |splittable_editor, cx| {
splittable_editor.rhs_editor.update(cx, |rhs_editor, cx| {
rhs_editor.remove_blocks(HashSet::from_iter([new_block_ids[0]]), None, cx);
});
});
cx.run_until_parked();
assert_split_content(
&editor,
"
§ <no file>
§ -----
aaa
bbb
ccc
§ custom block 2"
.unindent(),
"
§ <no file>
§ -----
§ spacer
bbb
ccc
§ custom block 2"
.unindent(),
&mut cx,
);
}
#[gpui::test]
async fn test_buffer_folding_sync(cx: &mut gpui::TestAppContext) {
use rope::Point;
use unindent::Unindent as _;
let (editor, mut cx) = init_test(cx, SoftWrap::None, DiffViewStyle::Unified).await;
let base_text1 = "
aaa
bbb
ccc"
.unindent();
let current_text1 = "
aaa
bbb
ccc"
.unindent();
let base_text2 = "
ddd
eee
fff"
.unindent();
let current_text2 = "
ddd
eee
fff"
.unindent();
let (buffer1, diff1) = buffer_with_diff(&base_text1, &current_text1, &mut cx);
let (buffer2, diff2) = buffer_with_diff(&base_text2, &current_text2, &mut cx);
let buffer1_id = buffer1.read_with(cx, |buffer, _| buffer.remote_id());
let buffer2_id = buffer2.read_with(cx, |buffer, _| buffer.remote_id());
editor.update(cx, |editor, cx| {
let path1 = PathKey::for_buffer(&buffer1, cx);
editor.update_excerpts_for_path(
path1,
buffer1.clone(),
vec![Point::new(0, 0)..buffer1.read(cx).max_point()],
0,
diff1.clone(),
cx,
);
let path2 = PathKey::for_buffer(&buffer2, cx);
editor.update_excerpts_for_path(
path2,
buffer2.clone(),
vec![Point::new(0, 0)..buffer2.read(cx).max_point()],
1,
diff2.clone(),
cx,
);
});
cx.run_until_parked();
editor.update(cx, |editor, cx| {
editor.rhs_editor.update(cx, |rhs_editor, cx| {
rhs_editor.fold_buffer(buffer1_id, cx);
});
});
cx.run_until_parked();
let rhs_buffer1_folded = editor.read_with(cx, |editor, cx| {
editor.rhs_editor.read(cx).is_buffer_folded(buffer1_id, cx)
});
assert!(
rhs_buffer1_folded,
"buffer1 should be folded in rhs before split"
);
editor.update_in(cx, |editor, window, cx| {
editor.split(window, cx);
});
cx.run_until_parked();
let (rhs_editor, lhs_editor) = editor.read_with(cx, |editor, _cx| {
(
editor.rhs_editor.clone(),
editor.lhs.as_ref().unwrap().editor.clone(),
)
});
let rhs_buffer1_folded =
rhs_editor.read_with(cx, |editor, cx| editor.is_buffer_folded(buffer1_id, cx));
assert!(
rhs_buffer1_folded,
"buffer1 should be folded in rhs after split"
);
let base_buffer1_id = diff1.read_with(cx, |diff, cx| diff.base_text(cx).remote_id());
let lhs_buffer1_folded = lhs_editor.read_with(cx, |editor, cx| {
editor.is_buffer_folded(base_buffer1_id, cx)
});
assert!(
lhs_buffer1_folded,
"buffer1 should be folded in lhs after split"
);
assert_split_content(
&editor,
"
§ <no file>
§ -----
§ <no file>
§ -----
ddd
eee
fff"
.unindent(),
"
§ <no file>
§ -----
§ <no file>
§ -----
ddd
eee
fff"
.unindent(),
&mut cx,
);
editor.update(cx, |editor, cx| {
editor.rhs_editor.update(cx, |rhs_editor, cx| {
rhs_editor.fold_buffer(buffer2_id, cx);
});
});
cx.run_until_parked();
let rhs_buffer2_folded =
rhs_editor.read_with(cx, |editor, cx| editor.is_buffer_folded(buffer2_id, cx));
assert!(rhs_buffer2_folded, "buffer2 should be folded in rhs");
let base_buffer2_id = diff2.read_with(cx, |diff, cx| diff.base_text(cx).remote_id());
let lhs_buffer2_folded = lhs_editor.read_with(cx, |editor, cx| {
editor.is_buffer_folded(base_buffer2_id, cx)
});
assert!(lhs_buffer2_folded, "buffer2 should be folded in lhs");
let rhs_buffer1_still_folded =
rhs_editor.read_with(cx, |editor, cx| editor.is_buffer_folded(buffer1_id, cx));
assert!(
rhs_buffer1_still_folded,
"buffer1 should still be folded in rhs"
);
let lhs_buffer1_still_folded = lhs_editor.read_with(cx, |editor, cx| {
editor.is_buffer_folded(base_buffer1_id, cx)
});
assert!(
lhs_buffer1_still_folded,
"buffer1 should still be folded in lhs"
);
assert_split_content(
&editor,
"
§ <no file>
§ -----
§ <no file>
§ -----"
.unindent(),
"
§ <no file>
§ -----
§ <no file>
§ -----"
.unindent(),
&mut cx,
);
}
#[gpui::test]
async fn test_custom_block_in_middle_of_added_hunk(cx: &mut gpui::TestAppContext) {
use rope::Point;
use unindent::Unindent as _;
let (editor, mut cx) = init_test(cx, SoftWrap::None, DiffViewStyle::Split).await;
let base_text = "
ddd
eee
"
.unindent();
let current_text = "
aaa
bbb
ccc
ddd
eee
"
.unindent();
let (buffer, diff) = buffer_with_diff(&base_text, &current_text, &mut cx);
editor.update(cx, |editor, cx| {
let path = PathKey::for_buffer(&buffer, cx);
editor.update_excerpts_for_path(
path,
buffer.clone(),
vec![Point::new(0, 0)..buffer.read(cx).max_point()],
0,
diff.clone(),
cx,
);
});
cx.run_until_parked();
assert_split_content(
&editor,
"
§ <no file>
§ -----
aaa
bbb
ccc
ddd
eee"
.unindent(),
"
§ <no file>
§ -----
§ spacer
§ spacer
§ spacer
ddd
eee"
.unindent(),
&mut cx,
);
let block_ids = editor.update(cx, |splittable_editor, cx| {
splittable_editor.rhs_editor.update(cx, |rhs_editor, cx| {
let snapshot = rhs_editor.buffer().read(cx).snapshot(cx);
let anchor = snapshot.anchor_before(Point::new(2, 0));
rhs_editor.insert_blocks(
[BlockProperties {
placement: BlockPlacement::Above(anchor),
height: Some(1),
style: BlockStyle::Fixed,
render: Arc::new(|_| div().into_any()),
priority: 0,
}],
None,
cx,
)
})
});
let rhs_editor = editor.read_with(cx, |editor, _| editor.rhs_editor.clone());
let lhs_editor =
editor.read_with(cx, |editor, _| editor.lhs.as_ref().unwrap().editor.clone());
cx.update(|_, cx| {
set_block_content_for_tests(&rhs_editor, block_ids[0], cx, |_| {
"custom block".to_string()
});
});
let lhs_block_id = lhs_editor.read_with(cx, |lhs_editor, cx| {
let display_map = lhs_editor.display_map.read(cx);
let companion = display_map.companion().unwrap().read(cx);
let mapping = companion
.custom_block_to_balancing_block(rhs_editor.read(cx).display_map.entity_id());
*mapping.borrow().get(&block_ids[0]).unwrap()
});
cx.update(|_, cx| {
set_block_content_for_tests(&lhs_editor, lhs_block_id, cx, |_| {
"custom block".to_string()
});
});
cx.run_until_parked();
assert_split_content(
&editor,
"
§ <no file>
§ -----
aaa
bbb
§ custom block
ccc
ddd
eee"
.unindent(),
"
§ <no file>
§ -----
§ spacer
§ spacer
§ spacer
§ custom block
ddd
eee"
.unindent(),
&mut cx,
);
editor.update(cx, |splittable_editor, cx| {
splittable_editor.rhs_editor.update(cx, |rhs_editor, cx| {
rhs_editor.remove_blocks(HashSet::from_iter(block_ids), None, cx);
});
});
cx.run_until_parked();
assert_split_content(
&editor,
"
§ <no file>
§ -----
aaa
bbb
ccc
ddd
eee"
.unindent(),
"
§ <no file>
§ -----
§ spacer
§ spacer
§ spacer
ddd
eee"
.unindent(),
&mut cx,
);
}
#[gpui::test]
async fn test_custom_block_below_in_middle_of_added_hunk(cx: &mut gpui::TestAppContext) {
use rope::Point;
use unindent::Unindent as _;
let (editor, mut cx) = init_test(cx, SoftWrap::None, DiffViewStyle::Split).await;
let base_text = "
ddd
eee
"
.unindent();
let current_text = "
aaa
bbb
ccc
ddd
eee
"
.unindent();
let (buffer, diff) = buffer_with_diff(&base_text, &current_text, &mut cx);
editor.update(cx, |editor, cx| {
let path = PathKey::for_buffer(&buffer, cx);
editor.update_excerpts_for_path(
path,
buffer.clone(),
vec![Point::new(0, 0)..buffer.read(cx).max_point()],
0,
diff.clone(),
cx,
);
});
cx.run_until_parked();
assert_split_content(
&editor,
"
§ <no file>
§ -----
aaa
bbb
ccc
ddd
eee"
.unindent(),
"
§ <no file>
§ -----
§ spacer
§ spacer
§ spacer
ddd
eee"
.unindent(),
&mut cx,
);
let block_ids = editor.update(cx, |splittable_editor, cx| {
splittable_editor.rhs_editor.update(cx, |rhs_editor, cx| {
let snapshot = rhs_editor.buffer().read(cx).snapshot(cx);
let anchor = snapshot.anchor_after(Point::new(1, 3));
rhs_editor.insert_blocks(
[BlockProperties {
placement: BlockPlacement::Below(anchor),
height: Some(1),
style: BlockStyle::Fixed,
render: Arc::new(|_| div().into_any()),
priority: 0,
}],
None,
cx,
)
})
});
let rhs_editor = editor.read_with(cx, |editor, _| editor.rhs_editor.clone());
let lhs_editor =
editor.read_with(cx, |editor, _| editor.lhs.as_ref().unwrap().editor.clone());
cx.update(|_, cx| {
set_block_content_for_tests(&rhs_editor, block_ids[0], cx, |_| {
"custom block".to_string()
});
});
let lhs_block_id = lhs_editor.read_with(cx, |lhs_editor, cx| {
let display_map = lhs_editor.display_map.read(cx);
let companion = display_map.companion().unwrap().read(cx);
let mapping = companion
.custom_block_to_balancing_block(rhs_editor.read(cx).display_map.entity_id());
*mapping.borrow().get(&block_ids[0]).unwrap()
});
cx.update(|_, cx| {
set_block_content_for_tests(&lhs_editor, lhs_block_id, cx, |_| {
"custom block".to_string()
});
});
cx.run_until_parked();
assert_split_content(
&editor,
"
§ <no file>
§ -----
aaa
bbb
§ custom block
ccc
ddd
eee"
.unindent(),
"
§ <no file>
§ -----
§ spacer
§ spacer
§ spacer
§ custom block
ddd
eee"
.unindent(),
&mut cx,
);
editor.update(cx, |splittable_editor, cx| {
splittable_editor.rhs_editor.update(cx, |rhs_editor, cx| {
rhs_editor.remove_blocks(HashSet::from_iter(block_ids), None, cx);
});
});
cx.run_until_parked();
assert_split_content(
&editor,
"
§ <no file>
§ -----
aaa
bbb
ccc
ddd
eee"
.unindent(),
"
§ <no file>
§ -----
§ spacer
§ spacer
§ spacer
ddd
eee"
.unindent(),
&mut cx,
);
}
#[gpui::test]
async fn test_custom_block_resize_syncs_balancing_block(cx: &mut gpui::TestAppContext) {
use rope::Point;
use unindent::Unindent as _;
let (editor, mut cx) = init_test(cx, SoftWrap::None, DiffViewStyle::Split).await;
let base_text = "
bbb
ccc
"
.unindent();
let current_text = "
aaa
bbb
ccc
"
.unindent();
let (buffer, diff) = buffer_with_diff(&base_text, &current_text, &mut cx);
editor.update(cx, |editor, cx| {
let path = PathKey::for_buffer(&buffer, cx);
editor.update_excerpts_for_path(
path,
buffer.clone(),
vec![Point::new(0, 0)..buffer.read(cx).max_point()],
0,
diff.clone(),
cx,
);
});
cx.run_until_parked();
let block_ids = editor.update(cx, |splittable_editor, cx| {
splittable_editor.rhs_editor.update(cx, |rhs_editor, cx| {
let snapshot = rhs_editor.buffer().read(cx).snapshot(cx);
let anchor = snapshot.anchor_before(Point::new(2, 0));
rhs_editor.insert_blocks(
[BlockProperties {
placement: BlockPlacement::Above(anchor),
height: Some(1),
style: BlockStyle::Fixed,
render: Arc::new(|_| div().into_any()),
priority: 0,
}],
None,
cx,
)
})
});
let rhs_editor = editor.read_with(cx, |editor, _| editor.rhs_editor.clone());
let lhs_editor =
editor.read_with(cx, |editor, _| editor.lhs.as_ref().unwrap().editor.clone());
let lhs_block_id = lhs_editor.read_with(cx, |lhs_editor, cx| {
let display_map = lhs_editor.display_map.read(cx);
let companion = display_map.companion().unwrap().read(cx);
let mapping = companion
.custom_block_to_balancing_block(rhs_editor.read(cx).display_map.entity_id());
*mapping.borrow().get(&block_ids[0]).unwrap()
});
cx.run_until_parked();
let get_block_height = |editor: &Entity<crate::Editor>,
block_id: crate::CustomBlockId,
cx: &mut VisualTestContext| {
editor.update_in(cx, |editor, window, cx| {
let snapshot = editor.snapshot(window, cx);
snapshot
.block_for_id(crate::BlockId::Custom(block_id))
.map(|block| block.height())
})
};
assert_eq!(
get_block_height(&rhs_editor, block_ids[0], &mut cx),
Some(1)
);
assert_eq!(
get_block_height(&lhs_editor, lhs_block_id, &mut cx),
Some(1)
);
editor.update(cx, |splittable_editor, cx| {
splittable_editor.rhs_editor.update(cx, |rhs_editor, cx| {
let mut heights = HashMap::default();
heights.insert(block_ids[0], 3);
rhs_editor.resize_blocks(heights, None, cx);
});
});
cx.run_until_parked();
assert_eq!(
get_block_height(&rhs_editor, block_ids[0], &mut cx),
Some(3)
);
assert_eq!(
get_block_height(&lhs_editor, lhs_block_id, &mut cx),
Some(3)
);
editor.update(cx, |splittable_editor, cx| {
splittable_editor.rhs_editor.update(cx, |rhs_editor, cx| {
let mut heights = HashMap::default();
heights.insert(block_ids[0], 5);
rhs_editor.resize_blocks(heights, None, cx);
});
});
cx.run_until_parked();
assert_eq!(
get_block_height(&rhs_editor, block_ids[0], &mut cx),
Some(5)
);
assert_eq!(
get_block_height(&lhs_editor, lhs_block_id, &mut cx),
Some(5)
);
}
#[gpui::test]
async fn test_edit_spanning_excerpt_boundaries_then_resplit(cx: &mut gpui::TestAppContext) {
use rope::Point;
use unindent::Unindent as _;
let (editor, mut cx) = init_test(cx, SoftWrap::None, DiffViewStyle::Split).await;
let base_text = "
aaa
bbb
ccc
ddd
eee
fff
ggg
hhh
iii
jjj
kkk
lll
"
.unindent();
let current_text = base_text.clone();
let (buffer, diff) = buffer_with_diff(&base_text, &current_text, &mut cx);
editor.update(cx, |editor, cx| {
let path = PathKey::for_buffer(&buffer, cx);
editor.update_excerpts_for_path(
path,
buffer.clone(),
vec![
Point::new(0, 0)..Point::new(3, 3),
Point::new(5, 0)..Point::new(8, 3),
Point::new(10, 0)..Point::new(11, 3),
],
0,
diff.clone(),
cx,
);
});
cx.run_until_parked();
buffer.update(cx, |buffer, cx| {
buffer.edit([(Point::new(1, 0)..Point::new(10, 0), "")], None, cx);
});
cx.run_until_parked();
editor.update_in(cx, |splittable_editor, window, cx| {
splittable_editor.unsplit(window, cx);
});
cx.run_until_parked();
editor.update_in(cx, |splittable_editor, window, cx| {
splittable_editor.split(window, cx);
});
cx.run_until_parked();
}
#[gpui::test]
async fn test_range_folds_removed_on_split(cx: &mut gpui::TestAppContext) {
use rope::Point;
use unindent::Unindent as _;
let (editor, mut cx) = init_test(cx, SoftWrap::None, DiffViewStyle::Unified).await;
let base_text = "
aaa
bbb
ccc
ddd
eee"
.unindent();
let current_text = "
aaa
bbb
ccc
ddd
eee"
.unindent();
let (buffer, diff) = buffer_with_diff(&base_text, &current_text, &mut cx);
editor.update(cx, |editor, cx| {
let path = PathKey::for_buffer(&buffer, cx);
editor.update_excerpts_for_path(
path,
buffer.clone(),
vec![Point::new(0, 0)..buffer.read(cx).max_point()],
0,
diff.clone(),
cx,
);
});
cx.run_until_parked();
editor.update_in(cx, |editor, window, cx| {
editor.rhs_editor.update(cx, |rhs_editor, cx| {
rhs_editor.fold_creases(
vec![Crease::simple(
Point::new(1, 0)..Point::new(3, 0),
FoldPlaceholder::test(),
)],
false,
window,
cx,
);
});
});
cx.run_until_parked();
editor.update_in(cx, |editor, window, cx| {
editor.split(window, cx);
});
cx.run_until_parked();
let (rhs_editor, lhs_editor) = editor.read_with(cx, |editor, _cx| {
(
editor.rhs_editor.clone(),
editor.lhs.as_ref().unwrap().editor.clone(),
)
});
let rhs_has_folds_after_split = rhs_editor.update(cx, |editor, cx| {
let snapshot = editor.display_snapshot(cx);
snapshot
.folds_in_range(MultiBufferOffset(0)..snapshot.buffer_snapshot().len())
.next()
.is_some()
});
assert!(
!rhs_has_folds_after_split,
"rhs should not have range folds after split"
);
let lhs_has_folds = lhs_editor.update(cx, |editor, cx| {
let snapshot = editor.display_snapshot(cx);
snapshot
.folds_in_range(MultiBufferOffset(0)..snapshot.buffer_snapshot().len())
.next()
.is_some()
});
assert!(!lhs_has_folds, "lhs should not have any range folds");
}
#[gpui::test]
async fn test_multiline_inlays_create_spacers(cx: &mut gpui::TestAppContext) {
use rope::Point;
use unindent::Unindent as _;
let (editor, mut cx) = init_test(cx, SoftWrap::None, DiffViewStyle::Split).await;
let base_text = "
aaa
bbb
ccc
ddd
"
.unindent();
let current_text = base_text.clone();
let (buffer, diff) = buffer_with_diff(&base_text, &current_text, &mut cx);
editor.update(cx, |editor, cx| {
let path = PathKey::for_buffer(&buffer, cx);
editor.update_excerpts_for_path(
path,
buffer.clone(),
vec![Point::new(0, 0)..Point::new(3, 3)],
0,
diff.clone(),
cx,
);
});
cx.run_until_parked();
let rhs_editor = editor.read_with(cx, |e, _| e.rhs_editor.clone());
rhs_editor.update(cx, |rhs_editor, cx| {
let snapshot = rhs_editor.buffer().read(cx).snapshot(cx);
rhs_editor.splice_inlays(
&[],
vec![
Inlay::edit_prediction(
0,
snapshot.anchor_after(Point::new(0, 3)),
"\nINLAY_WITHIN",
),
Inlay::edit_prediction(
1,
snapshot.anchor_after(Point::new(1, 3)),
"\nINLAY_MID_1\nINLAY_MID_2",
),
Inlay::edit_prediction(
2,
snapshot.anchor_after(Point::new(3, 3)),
"\nINLAY_END_1\nINLAY_END_2",
),
],
cx,
);
});
cx.run_until_parked();
assert_split_content(
&editor,
"
§ <no file>
§ -----
aaa
INLAY_WITHIN
bbb
INLAY_MID_1
INLAY_MID_2
ccc
ddd
INLAY_END_1
INLAY_END_2"
.unindent(),
"
§ <no file>
§ -----
aaa
§ spacer
bbb
§ spacer
§ spacer
ccc
ddd
§ spacer
§ spacer"
.unindent(),
&mut cx,
);
}
#[gpui::test]
async fn test_split_after_removing_folded_buffer(cx: &mut gpui::TestAppContext) {
use rope::Point;
use unindent::Unindent as _;
let (editor, mut cx) = init_test(cx, SoftWrap::None, DiffViewStyle::Unified).await;
let base_text_a = "
aaa
bbb
ccc
"
.unindent();
let current_text_a = "
aaa
bbb modified
ccc
"
.unindent();
let base_text_b = "
xxx
yyy
zzz
"
.unindent();
let current_text_b = "
xxx
yyy modified
zzz
"
.unindent();
let (buffer_a, diff_a) = buffer_with_diff(&base_text_a, &current_text_a, &mut cx);
let (buffer_b, diff_b) = buffer_with_diff(&base_text_b, &current_text_b, &mut cx);
let path_a = cx.read(|cx| PathKey::for_buffer(&buffer_a, cx));
let path_b = cx.read(|cx| PathKey::for_buffer(&buffer_b, cx));
editor.update(cx, |editor, cx| {
editor.update_excerpts_for_path(
path_a.clone(),
buffer_a.clone(),
vec![Point::new(0, 0)..buffer_a.read(cx).max_point()],
0,
diff_a.clone(),
cx,
);
editor.update_excerpts_for_path(
path_b.clone(),
buffer_b.clone(),
vec![Point::new(0, 0)..buffer_b.read(cx).max_point()],
0,
diff_b.clone(),
cx,
);
});
cx.run_until_parked();
let buffer_a_id = buffer_a.read_with(cx, |buffer, _| buffer.remote_id());
editor.update(cx, |editor, cx| {
editor.rhs_editor().update(cx, |right_editor, cx| {
right_editor.fold_buffer(buffer_a_id, cx)
});
});
cx.run_until_parked();
editor.update(cx, |editor, cx| {
editor.remove_excerpts_for_path(path_a.clone(), cx);
});
cx.run_until_parked();
editor.update_in(cx, |editor, window, cx| editor.split(window, cx));
cx.run_until_parked();
editor.update(cx, |editor, cx| {
editor.update_excerpts_for_path(
path_a.clone(),
buffer_a.clone(),
vec![Point::new(0, 0)..buffer_a.read(cx).max_point()],
0,
diff_a.clone(),
cx,
);
assert!(
!editor
.lhs_editor()
.unwrap()
.read(cx)
.is_buffer_folded(buffer_a_id, cx)
);
assert!(
!editor
.rhs_editor()
.read(cx)
.is_buffer_folded(buffer_a_id, cx)
);
});
}
#[gpui::test]
async fn test_two_path_keys_for_one_buffer(cx: &mut gpui::TestAppContext) {
use multi_buffer::PathKey;
use rope::Point;
use unindent::Unindent as _;
let (editor, mut cx) = init_test(cx, SoftWrap::None, DiffViewStyle::Split).await;
let base_text = "
aaa
bbb
ccc
"
.unindent();
let current_text = "
aaa
bbb modified
ccc
"
.unindent();
let (buffer, diff) = buffer_with_diff(&base_text, &current_text, &mut cx);
let path_key_1 = PathKey {
sort_prefix: Some(0),
path: rel_path("file1.txt").into(),
};
let path_key_2 = PathKey {
sort_prefix: Some(1),
path: rel_path("file1.txt").into(),
};
editor.update(cx, |editor, cx| {
editor.update_excerpts_for_path(
path_key_1.clone(),
buffer.clone(),
vec![Point::new(0, 0)..Point::new(1, 0)],
0,
diff.clone(),
cx,
);
editor.update_excerpts_for_path(
path_key_2.clone(),
buffer.clone(),
vec![Point::new(1, 0)..buffer.read(cx).max_point()],
1,
diff.clone(),
cx,
);
});
cx.run_until_parked();
}
#[gpui::test]
async fn test_act_as_type(cx: &mut gpui::TestAppContext) {
let (splittable_editor, cx) = init_test(cx, SoftWrap::None, DiffViewStyle::Split).await;
let editor = splittable_editor.read_with(cx, |editor, cx| {
editor.act_as_type(TypeId::of::<Editor>(), &splittable_editor, cx)
});
assert!(
editor.is_some(),
"SplittableEditor should be able to act as Editor"
);
}
}