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>
950 lines
30 KiB
Rust
950 lines
30 KiB
Rust
//! TextDiffView currently provides a UI for displaying differences between the clipboard and selected text.
|
|
|
|
use anyhow::Result;
|
|
use buffer_diff::BufferDiff;
|
|
use editor::{
|
|
Editor, EditorEvent, EditorSettings, MultiBuffer, SplittableEditor, ToPoint,
|
|
actions::DiffClipboardWithSelectionData,
|
|
};
|
|
use futures::{FutureExt, select_biased};
|
|
use gpui::{
|
|
AnyElement, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, FocusHandle,
|
|
Focusable, IntoElement, Render, Task, Window,
|
|
};
|
|
use language::{self, Buffer, OffsetRangeExt, Point};
|
|
use project::Project;
|
|
use settings::Settings;
|
|
use std::{
|
|
any::{Any, TypeId},
|
|
cmp,
|
|
ops::Range,
|
|
pin::pin,
|
|
sync::Arc,
|
|
time::Duration,
|
|
};
|
|
use ui::{Color, Icon, IconName, Label, LabelCommon as _, SharedString};
|
|
use util::paths::PathExt;
|
|
|
|
use workspace::{
|
|
Item, ItemNavHistory, Workspace,
|
|
item::{ItemEvent, SaveOptions, TabContentParams},
|
|
searchable::SearchableItemHandle,
|
|
};
|
|
|
|
pub struct TextDiffView {
|
|
diff_editor: Entity<SplittableEditor>,
|
|
title: SharedString,
|
|
path: Option<SharedString>,
|
|
buffer_changes_tx: watch::Sender<()>,
|
|
_recalculate_diff_task: Task<Result<()>>,
|
|
}
|
|
|
|
const RECALCULATE_DIFF_DEBOUNCE: Duration = Duration::from_millis(250);
|
|
|
|
impl TextDiffView {
|
|
pub fn open(
|
|
diff_data: &DiffClipboardWithSelectionData,
|
|
workspace: &Workspace,
|
|
window: &mut Window,
|
|
cx: &mut App,
|
|
) -> Option<Task<Result<Entity<Self>>>> {
|
|
let source_editor = diff_data.editor.clone();
|
|
|
|
let selection_data = source_editor.update(cx, |editor, cx| {
|
|
let multibuffer = editor.buffer();
|
|
let multibuffer_snapshot = multibuffer.read(cx).snapshot(cx);
|
|
let first_selection = editor.selections.newest_anchor();
|
|
|
|
let (source_buffer, buffer_range) = multibuffer_snapshot
|
|
.anchor_range_to_buffer_anchor_range(first_selection.range())?;
|
|
let max_point = source_buffer.max_point();
|
|
let buffer_range = buffer_range.to_point(source_buffer);
|
|
let source_buffer = multibuffer.read(cx).buffer(source_buffer.remote_id())?;
|
|
|
|
if buffer_range.is_empty() {
|
|
let full_range = Point::new(0, 0)..max_point;
|
|
return Some((source_buffer, full_range));
|
|
}
|
|
|
|
let expanded_start = Point::new(buffer_range.start.row, 0);
|
|
let expanded_end = if buffer_range.end.column > 0 {
|
|
let next_row = buffer_range.end.row + 1;
|
|
cmp::min(max_point, Point::new(next_row, 0))
|
|
} else {
|
|
buffer_range.end
|
|
};
|
|
Some((source_buffer, expanded_start..expanded_end))
|
|
});
|
|
|
|
let Some((source_buffer, expanded_selection_range)) = selection_data else {
|
|
log::warn!("There should always be at least one selection in Zed. This is a bug.");
|
|
return None;
|
|
};
|
|
|
|
source_editor.update(cx, |source_editor, cx| {
|
|
let multibuffer = source_editor.buffer();
|
|
let mb_range = {
|
|
let mb = multibuffer.read(cx);
|
|
let start_anchor =
|
|
mb.buffer_point_to_anchor(&source_buffer, expanded_selection_range.start, cx);
|
|
let end_anchor =
|
|
mb.buffer_point_to_anchor(&source_buffer, expanded_selection_range.end, cx);
|
|
start_anchor.zip(end_anchor).map(|(s, e)| {
|
|
let snapshot = mb.snapshot(cx);
|
|
s.to_point(&snapshot)..e.to_point(&snapshot)
|
|
})
|
|
};
|
|
|
|
if let Some(range) = mb_range {
|
|
source_editor.change_selections(Default::default(), window, cx, |s| {
|
|
s.select_ranges(vec![range]);
|
|
});
|
|
}
|
|
});
|
|
|
|
let source_buffer_snapshot = source_buffer.read(cx).snapshot();
|
|
let mut clipboard_text = diff_data.clipboard_text.clone();
|
|
|
|
if !clipboard_text.ends_with("\n") {
|
|
clipboard_text.push_str("\n");
|
|
}
|
|
|
|
let workspace = workspace.weak_handle();
|
|
let diff_buffer = cx.new(|cx| BufferDiff::new(&source_buffer_snapshot.text, cx));
|
|
let clipboard_buffer = build_clipboard_buffer(
|
|
clipboard_text,
|
|
&source_buffer,
|
|
expanded_selection_range.clone(),
|
|
cx,
|
|
);
|
|
|
|
let task = window.spawn(cx, async move |cx| {
|
|
update_diff_buffer(&diff_buffer, &source_buffer, &clipboard_buffer, cx).await?;
|
|
|
|
workspace.update_in(cx, |workspace, window, cx| {
|
|
let project = workspace.project().clone();
|
|
let workspace_entity = cx.entity();
|
|
let diff_view = cx.new(|cx| {
|
|
TextDiffView::new(
|
|
clipboard_buffer,
|
|
source_editor,
|
|
source_buffer,
|
|
expanded_selection_range,
|
|
diff_buffer,
|
|
project,
|
|
workspace_entity,
|
|
window,
|
|
cx,
|
|
)
|
|
});
|
|
|
|
let pane = workspace.active_pane();
|
|
pane.update(cx, |pane, cx| {
|
|
pane.add_item(Box::new(diff_view.clone()), true, true, None, window, cx);
|
|
});
|
|
|
|
diff_view
|
|
})
|
|
});
|
|
|
|
Some(task)
|
|
}
|
|
|
|
pub fn new(
|
|
clipboard_buffer: Entity<Buffer>,
|
|
source_editor: Entity<Editor>,
|
|
source_buffer: Entity<Buffer>,
|
|
source_range: Range<Point>,
|
|
diff_buffer: Entity<BufferDiff>,
|
|
project: Entity<Project>,
|
|
workspace: Entity<Workspace>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> Self {
|
|
let multibuffer = cx.new(|cx| {
|
|
let mut multibuffer = MultiBuffer::new(language::Capability::ReadWrite);
|
|
|
|
multibuffer.set_excerpts_for_buffer(source_buffer.clone(), [source_range], 0, cx);
|
|
|
|
multibuffer.add_diff(diff_buffer.clone(), cx);
|
|
multibuffer
|
|
});
|
|
let diff_editor = cx.new(|cx| {
|
|
let splittable = SplittableEditor::new(
|
|
EditorSettings::get_global(cx).diff_view_style,
|
|
multibuffer,
|
|
project,
|
|
workspace,
|
|
window,
|
|
cx,
|
|
);
|
|
splittable.set_render_diff_hunk_controls(
|
|
Arc::new(|_, _, _, _, _, _, _, _| gpui::Empty.into_any_element()),
|
|
cx,
|
|
);
|
|
splittable.rhs_editor().update(cx, |editor, cx| {
|
|
editor.start_temporary_diff_override();
|
|
editor.disable_diagnostics(cx);
|
|
editor.set_expand_all_diff_hunks(cx);
|
|
});
|
|
splittable
|
|
});
|
|
|
|
let (buffer_changes_tx, mut buffer_changes_rx) = watch::channel(());
|
|
|
|
cx.subscribe(&source_buffer, move |this, _, event, _| match event {
|
|
language::BufferEvent::Edited { .. }
|
|
| language::BufferEvent::LanguageChanged(_)
|
|
| language::BufferEvent::Reparsed => {
|
|
this.buffer_changes_tx.send(()).ok();
|
|
}
|
|
_ => {}
|
|
})
|
|
.detach();
|
|
|
|
let editor = source_editor.read(cx);
|
|
let title = editor.buffer().read(cx).title(cx).to_string();
|
|
let selection_location_text = selection_location_text(editor, cx);
|
|
let selection_location_title = selection_location_text
|
|
.as_ref()
|
|
.map(|text| format!("{} @ {}", title, text))
|
|
.unwrap_or(title);
|
|
|
|
let path = editor
|
|
.buffer()
|
|
.read(cx)
|
|
.as_singleton()
|
|
.and_then(|b| {
|
|
b.read(cx)
|
|
.file()
|
|
.map(|f| f.full_path(cx).compact().to_string_lossy().into_owned())
|
|
})
|
|
.unwrap_or("untitled".into());
|
|
|
|
let selection_location_path = selection_location_text
|
|
.map(|text| format!("{} @ {}", path, text))
|
|
.unwrap_or(path);
|
|
|
|
Self {
|
|
diff_editor,
|
|
title: format!("Clipboard ↔ {selection_location_title}").into(),
|
|
path: Some(format!("Clipboard ↔ {selection_location_path}").into()),
|
|
buffer_changes_tx,
|
|
_recalculate_diff_task: cx.spawn(async move |_, cx| {
|
|
while buffer_changes_rx.recv().await.is_ok() {
|
|
loop {
|
|
let mut timer = cx
|
|
.background_executor()
|
|
.timer(RECALCULATE_DIFF_DEBOUNCE)
|
|
.fuse();
|
|
let mut recv = pin!(buffer_changes_rx.recv().fuse());
|
|
select_biased! {
|
|
_ = timer => break,
|
|
_ = recv => continue,
|
|
}
|
|
}
|
|
|
|
log::trace!("start recalculating");
|
|
update_diff_buffer(&diff_buffer, &source_buffer, &clipboard_buffer, cx).await?;
|
|
log::trace!("finish recalculating");
|
|
}
|
|
Ok(())
|
|
}),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn build_clipboard_buffer(
|
|
text: String,
|
|
source_buffer: &Entity<Buffer>,
|
|
replacement_range: Range<Point>,
|
|
cx: &mut App,
|
|
) -> Entity<Buffer> {
|
|
let source_buffer_snapshot = source_buffer.read(cx).snapshot();
|
|
cx.new(|cx| {
|
|
let mut buffer = language::Buffer::local(source_buffer_snapshot.text(), cx);
|
|
let language = source_buffer.read(cx).language().cloned();
|
|
buffer.set_language(language, cx);
|
|
|
|
let range_start = source_buffer_snapshot.point_to_offset(replacement_range.start);
|
|
let range_end = source_buffer_snapshot.point_to_offset(replacement_range.end);
|
|
buffer.edit([(range_start..range_end, text)], None, cx);
|
|
|
|
buffer
|
|
})
|
|
}
|
|
|
|
async fn update_diff_buffer(
|
|
diff: &Entity<BufferDiff>,
|
|
source_buffer: &Entity<Buffer>,
|
|
clipboard_buffer: &Entity<Buffer>,
|
|
cx: &mut AsyncApp,
|
|
) -> Result<()> {
|
|
let source_buffer_snapshot = source_buffer.read_with(cx, |buffer, _| buffer.snapshot());
|
|
let language = source_buffer_snapshot.language().cloned();
|
|
let language_registry = source_buffer.read_with(cx, |buffer, _| buffer.language_registry());
|
|
|
|
let base_buffer_snapshot = clipboard_buffer.read_with(cx, |buffer, _| buffer.snapshot());
|
|
let base_text = base_buffer_snapshot.text();
|
|
|
|
let update = diff
|
|
.update(cx, |diff, cx| {
|
|
diff.update_diff(
|
|
source_buffer_snapshot.text.clone(),
|
|
Some(Arc::from(base_text.as_str())),
|
|
Some(true),
|
|
language.clone(),
|
|
cx,
|
|
)
|
|
})
|
|
.await;
|
|
|
|
diff.update(cx, |diff, cx| {
|
|
diff.language_changed(language, language_registry, cx);
|
|
diff.set_snapshot(update, &source_buffer_snapshot.text, cx)
|
|
})
|
|
.await;
|
|
Ok(())
|
|
}
|
|
|
|
impl EventEmitter<EditorEvent> for TextDiffView {}
|
|
|
|
impl Focusable for TextDiffView {
|
|
fn focus_handle(&self, cx: &App) -> FocusHandle {
|
|
self.diff_editor.focus_handle(cx)
|
|
}
|
|
}
|
|
|
|
impl Item for TextDiffView {
|
|
type Event = EditorEvent;
|
|
|
|
fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
|
|
Some(Icon::new(IconName::Diff).color(Color::Muted))
|
|
}
|
|
|
|
fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
|
|
Label::new(self.tab_content_text(params.detail.unwrap_or_default(), cx))
|
|
.color(if params.selected {
|
|
Color::Default
|
|
} else {
|
|
Color::Muted
|
|
})
|
|
.into_any_element()
|
|
}
|
|
|
|
fn tab_content_text(&self, _detail: usize, _: &App) -> SharedString {
|
|
self.title.clone()
|
|
}
|
|
|
|
fn tab_tooltip_text(&self, _: &App) -> Option<SharedString> {
|
|
self.path.clone()
|
|
}
|
|
|
|
fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) {
|
|
Editor::to_item_events(event, f)
|
|
}
|
|
|
|
fn telemetry_event_text(&self) -> Option<&'static str> {
|
|
Some("Selection Diff View Opened")
|
|
}
|
|
|
|
fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
|
self.diff_editor
|
|
.update(cx, |editor, cx| editor.deactivated(window, cx));
|
|
}
|
|
|
|
fn act_as_type<'a>(
|
|
&'a self,
|
|
type_id: TypeId,
|
|
self_handle: &'a Entity<Self>,
|
|
cx: &'a App,
|
|
) -> Option<gpui::AnyEntity> {
|
|
if type_id == TypeId::of::<Self>() {
|
|
Some(self_handle.clone().into())
|
|
} else if type_id == TypeId::of::<SplittableEditor>() {
|
|
Some(self.diff_editor.clone().into())
|
|
} else if type_id == TypeId::of::<Editor>() {
|
|
Some(self.diff_editor.read(cx).rhs_editor().clone().into())
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn as_searchable(&self, _: &Entity<Self>, _: &App) -> Option<Box<dyn SearchableItemHandle>> {
|
|
Some(Box::new(self.diff_editor.clone()))
|
|
}
|
|
|
|
fn for_each_project_item(
|
|
&self,
|
|
cx: &App,
|
|
f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
|
|
) {
|
|
self.diff_editor.read(cx).for_each_project_item(cx, f)
|
|
}
|
|
|
|
fn set_nav_history(
|
|
&mut self,
|
|
nav_history: ItemNavHistory,
|
|
_: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let rhs = self.diff_editor.read(cx).rhs_editor().clone();
|
|
rhs.update(cx, |editor, _| {
|
|
editor.set_nav_history(Some(nav_history));
|
|
});
|
|
}
|
|
|
|
fn navigate(
|
|
&mut self,
|
|
data: Arc<dyn Any + Send>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> bool {
|
|
self.diff_editor
|
|
.update(cx, |editor, cx| editor.navigate(data, window, cx))
|
|
}
|
|
|
|
fn added_to_workspace(
|
|
&mut self,
|
|
workspace: &mut Workspace,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
self.diff_editor.update(cx, |editor, cx| {
|
|
editor.added_to_workspace(workspace, window, cx)
|
|
});
|
|
}
|
|
|
|
fn can_save(&self, cx: &App) -> bool {
|
|
// The editor handles the new buffer, so delegate to it
|
|
self.diff_editor.read(cx).can_save(cx)
|
|
}
|
|
|
|
fn save(
|
|
&mut self,
|
|
options: SaveOptions,
|
|
project: Entity<Project>,
|
|
window: &mut Window,
|
|
cx: &mut Context<Self>,
|
|
) -> Task<Result<()>> {
|
|
// Delegate saving to the editor, which manages the new buffer
|
|
self.diff_editor
|
|
.update(cx, |editor, cx| editor.save(options, project, window, cx))
|
|
}
|
|
}
|
|
|
|
pub fn selection_location_text(editor: &Editor, cx: &App) -> Option<String> {
|
|
let buffer = editor.buffer().read(cx);
|
|
let buffer_snapshot = buffer.snapshot(cx);
|
|
let first_selection = editor.selections.disjoint_anchors().first()?;
|
|
|
|
let selection_start = first_selection.start.to_point(&buffer_snapshot);
|
|
let selection_end = first_selection.end.to_point(&buffer_snapshot);
|
|
|
|
let start_row = selection_start.row;
|
|
let start_column = selection_start.column;
|
|
let end_row = selection_end.row;
|
|
let end_column = selection_end.column;
|
|
|
|
let range_text = if start_row == end_row {
|
|
format!("L{}:{}-{}", start_row + 1, start_column + 1, end_column + 1)
|
|
} else {
|
|
format!(
|
|
"L{}:{}-L{}:{}",
|
|
start_row + 1,
|
|
start_column + 1,
|
|
end_row + 1,
|
|
end_column + 1
|
|
)
|
|
};
|
|
|
|
Some(range_text)
|
|
}
|
|
|
|
impl Render for TextDiffView {
|
|
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
|
self.diff_editor.clone()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use editor::{MultiBufferOffset, PathKey, test::editor_test_context::assert_state_with_diff};
|
|
use gpui::{BorrowAppContext, TestAppContext, VisualContext};
|
|
use language::Point;
|
|
use project::{FakeFs, Project};
|
|
use serde_json::json;
|
|
use settings::{DiffViewStyle, SettingsStore};
|
|
use unindent::unindent;
|
|
use util::{path, test::marked_text_ranges};
|
|
use workspace::MultiWorkspace;
|
|
|
|
fn init_test(cx: &mut TestAppContext) {
|
|
cx.update(|cx| {
|
|
let settings_store = SettingsStore::test(cx);
|
|
cx.set_global(settings_store);
|
|
cx.update_global::<SettingsStore, _>(|store, cx| {
|
|
store.update_user_settings(cx, |settings| {
|
|
settings.editor.diff_view_style = Some(DiffViewStyle::Unified);
|
|
});
|
|
});
|
|
theme_settings::init(theme::LoadThemes::JustBase, cx);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_diffing_clipboard_against_empty_selection_uses_full_buffer_selection(
|
|
cx: &mut TestAppContext,
|
|
) {
|
|
base_test(
|
|
path!("/test"),
|
|
path!("/test/text.txt"),
|
|
"def process_incoming_inventory(items, warehouse_id):\n pass\n",
|
|
"def process_outgoing_inventory(items, warehouse_id):\n passˇ\n",
|
|
&unindent(
|
|
"
|
|
- def process_incoming_inventory(items, warehouse_id):
|
|
+ ˇdef process_outgoing_inventory(items, warehouse_id):
|
|
pass
|
|
",
|
|
),
|
|
"Clipboard ↔ text.txt @ L1:1-L3:1",
|
|
&format!("Clipboard ↔ {} @ L1:1-L3:1", path!("test/text.txt")),
|
|
cx,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_diffing_clipboard_against_multiline_selection_expands_to_full_lines(
|
|
cx: &mut TestAppContext,
|
|
) {
|
|
base_test(
|
|
path!("/test"),
|
|
path!("/test/text.txt"),
|
|
"def process_incoming_inventory(items, warehouse_id):\n pass\n",
|
|
"«def process_outgoing_inventory(items, warehouse_id):\n passˇ»\n",
|
|
&unindent(
|
|
"
|
|
- def process_incoming_inventory(items, warehouse_id):
|
|
+ ˇdef process_outgoing_inventory(items, warehouse_id):
|
|
pass
|
|
",
|
|
),
|
|
"Clipboard ↔ text.txt @ L1:1-L3:1",
|
|
&format!("Clipboard ↔ {} @ L1:1-L3:1", path!("test/text.txt")),
|
|
cx,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_diffing_clipboard_against_single_line_selection(cx: &mut TestAppContext) {
|
|
base_test(
|
|
path!("/test"),
|
|
path!("/test/text.txt"),
|
|
"a",
|
|
"«bbˇ»",
|
|
&unindent(
|
|
"
|
|
- a
|
|
+ ˇbb",
|
|
),
|
|
"Clipboard ↔ text.txt @ L1:1-3",
|
|
&format!("Clipboard ↔ {} @ L1:1-3", path!("test/text.txt")),
|
|
cx,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_diffing_clipboard_with_leading_whitespace_against_line(cx: &mut TestAppContext) {
|
|
base_test(
|
|
path!("/test"),
|
|
path!("/test/text.txt"),
|
|
" a",
|
|
"«bbˇ»",
|
|
&unindent(
|
|
"
|
|
- a
|
|
+ ˇbb",
|
|
),
|
|
"Clipboard ↔ text.txt @ L1:1-3",
|
|
&format!("Clipboard ↔ {} @ L1:1-3", path!("test/text.txt")),
|
|
cx,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_diffing_clipboard_against_line_with_leading_whitespace(cx: &mut TestAppContext) {
|
|
base_test(
|
|
path!("/test"),
|
|
path!("/test/text.txt"),
|
|
"a",
|
|
" «bbˇ»",
|
|
&unindent(
|
|
"
|
|
- a
|
|
+ ˇ bb",
|
|
),
|
|
"Clipboard ↔ text.txt @ L1:1-7",
|
|
&format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
|
|
cx,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_diffing_clipboard_against_line_with_leading_whitespace_included_in_selection(
|
|
cx: &mut TestAppContext,
|
|
) {
|
|
base_test(
|
|
path!("/test"),
|
|
path!("/test/text.txt"),
|
|
"a",
|
|
"« bbˇ»",
|
|
&unindent(
|
|
"
|
|
- a
|
|
+ ˇ bb",
|
|
),
|
|
"Clipboard ↔ text.txt @ L1:1-7",
|
|
&format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
|
|
cx,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_diffing_clipboard_with_leading_whitespace_against_line_with_leading_whitespace(
|
|
cx: &mut TestAppContext,
|
|
) {
|
|
base_test(
|
|
path!("/test"),
|
|
path!("/test/text.txt"),
|
|
" a",
|
|
" «bbˇ»",
|
|
&unindent(
|
|
"
|
|
- a
|
|
+ ˇ bb",
|
|
),
|
|
"Clipboard ↔ text.txt @ L1:1-7",
|
|
&format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
|
|
cx,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_diffing_clipboard_with_leading_whitespace_against_line_with_leading_whitespace_included_in_selection(
|
|
cx: &mut TestAppContext,
|
|
) {
|
|
base_test(
|
|
path!("/test"),
|
|
path!("/test/text.txt"),
|
|
" a",
|
|
"« bbˇ»",
|
|
&unindent(
|
|
"
|
|
- a
|
|
+ ˇ bb",
|
|
),
|
|
"Clipboard ↔ text.txt @ L1:1-7",
|
|
&format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
|
|
cx,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_diffing_clipboard_against_partial_selection_expands_to_include_trailing_characters(
|
|
cx: &mut TestAppContext,
|
|
) {
|
|
base_test(
|
|
path!("/test"),
|
|
path!("/test/text.txt"),
|
|
"a",
|
|
"«bˇ»b",
|
|
&unindent(
|
|
"
|
|
- a
|
|
+ ˇbb",
|
|
),
|
|
"Clipboard ↔ text.txt @ L1:1-3",
|
|
&format!("Clipboard ↔ {} @ L1:1-3", path!("test/text.txt")),
|
|
cx,
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_diffing_clipboard_from_multibuffer_with_selection(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
|
|
let fs = FakeFs::new(cx.executor());
|
|
fs.insert_tree(
|
|
path!("/project"),
|
|
json!({
|
|
"a.txt": "alpha\nbeta\ngamma",
|
|
"b.txt": "one\ntwo\nthree"
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
|
|
|
|
let buffer_a = project
|
|
.update(cx, |project, cx| {
|
|
project.open_local_buffer(path!("/project/a.txt"), cx)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let buffer_b = project
|
|
.update(cx, |project, cx| {
|
|
project.open_local_buffer(path!("/project/b.txt"), cx)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
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 editor = cx.new_window_entity(|window, cx| {
|
|
let multibuffer = cx.new(|cx| {
|
|
let mut mb = MultiBuffer::new(language::Capability::ReadWrite);
|
|
mb.set_excerpts_for_path(
|
|
PathKey::sorted(0),
|
|
buffer_a.clone(),
|
|
[Point::new(0, 0)..Point::new(2, 5)],
|
|
0,
|
|
cx,
|
|
);
|
|
mb.set_excerpts_for_path(
|
|
PathKey::sorted(1),
|
|
buffer_b.clone(),
|
|
[Point::new(0, 0)..Point::new(2, 5)],
|
|
0,
|
|
cx,
|
|
);
|
|
mb
|
|
});
|
|
|
|
let mut editor =
|
|
Editor::for_multibuffer(multibuffer, Some(project.clone()), window, cx);
|
|
// Select "beta" inside the first excerpt
|
|
editor.change_selections(Default::default(), window, cx, |s| {
|
|
s.select_ranges([MultiBufferOffset(6)..MultiBufferOffset(10)]);
|
|
});
|
|
editor
|
|
});
|
|
|
|
let diff_view = workspace
|
|
.update_in(cx, |workspace, window, cx| {
|
|
TextDiffView::open(
|
|
&DiffClipboardWithSelectionData {
|
|
clipboard_text: "REPLACED".to_string(),
|
|
editor,
|
|
},
|
|
workspace,
|
|
window,
|
|
cx,
|
|
)
|
|
})
|
|
.unwrap()
|
|
.await
|
|
.unwrap();
|
|
|
|
cx.executor().run_until_parked();
|
|
|
|
diff_view.read_with(cx, |diff_view, _cx| {
|
|
assert!(
|
|
diff_view.title.contains("Clipboard"),
|
|
"diff view should have opened with a clipboard diff title, got: {}",
|
|
diff_view.title
|
|
);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_diffing_clipboard_from_multibuffer_with_empty_selection(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
|
|
let fs = FakeFs::new(cx.executor());
|
|
fs.insert_tree(
|
|
path!("/project"),
|
|
json!({
|
|
"a.txt": "alpha\nbeta\ngamma",
|
|
"b.txt": "one\ntwo\nthree"
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
|
|
|
|
let buffer_a = project
|
|
.update(cx, |project, cx| {
|
|
project.open_local_buffer(path!("/project/a.txt"), cx)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
let buffer_b = project
|
|
.update(cx, |project, cx| {
|
|
project.open_local_buffer(path!("/project/b.txt"), cx)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
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 editor = cx.new_window_entity(|window, cx| {
|
|
let multibuffer = cx.new(|cx| {
|
|
let mut mb = MultiBuffer::new(language::Capability::ReadWrite);
|
|
mb.set_excerpts_for_path(
|
|
PathKey::sorted(0),
|
|
buffer_a.clone(),
|
|
[Point::new(0, 0)..Point::new(2, 5)],
|
|
0,
|
|
cx,
|
|
);
|
|
mb.set_excerpts_for_path(
|
|
PathKey::sorted(1),
|
|
buffer_b.clone(),
|
|
[Point::new(0, 0)..Point::new(2, 5)],
|
|
0,
|
|
cx,
|
|
);
|
|
mb
|
|
});
|
|
|
|
let mut editor =
|
|
Editor::for_multibuffer(multibuffer, Some(project.clone()), window, cx);
|
|
// Cursor inside the first excerpt (no selection)
|
|
editor.change_selections(Default::default(), window, cx, |s| {
|
|
s.select_ranges([MultiBufferOffset(6)..MultiBufferOffset(6)]);
|
|
});
|
|
editor
|
|
});
|
|
|
|
let diff_view = workspace
|
|
.update_in(cx, |workspace, window, cx| {
|
|
TextDiffView::open(
|
|
&DiffClipboardWithSelectionData {
|
|
clipboard_text: "REPLACED".to_string(),
|
|
editor,
|
|
},
|
|
workspace,
|
|
window,
|
|
cx,
|
|
)
|
|
})
|
|
.unwrap()
|
|
.await
|
|
.unwrap();
|
|
|
|
cx.executor().run_until_parked();
|
|
|
|
// Empty selection should diff the full underlying buffer
|
|
diff_view.read_with(cx, |diff_view, _cx| {
|
|
assert!(
|
|
diff_view.title.contains("Clipboard"),
|
|
"diff view should have opened with a clipboard diff title, got: {}",
|
|
diff_view.title
|
|
);
|
|
});
|
|
}
|
|
|
|
async fn base_test(
|
|
project_root: &str,
|
|
file_path: &str,
|
|
clipboard_text: &str,
|
|
editor_text: &str,
|
|
expected_diff: &str,
|
|
expected_tab_title: &str,
|
|
expected_tab_tooltip: &str,
|
|
cx: &mut TestAppContext,
|
|
) {
|
|
init_test(cx);
|
|
|
|
let file_name = std::path::Path::new(file_path)
|
|
.file_name()
|
|
.unwrap()
|
|
.to_str()
|
|
.unwrap();
|
|
|
|
let fs = FakeFs::new(cx.executor());
|
|
fs.insert_tree(
|
|
project_root,
|
|
json!({
|
|
file_name: editor_text
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let project = Project::test(fs, [project_root.as_ref()], 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 buffer = project
|
|
.update(cx, |project, cx| project.open_local_buffer(file_path, cx))
|
|
.await
|
|
.unwrap();
|
|
|
|
let editor = cx.new_window_entity(|window, cx| {
|
|
let mut editor = Editor::for_buffer(buffer, None, window, cx);
|
|
let (unmarked_text, selection_ranges) = marked_text_ranges(editor_text, false);
|
|
editor.set_text(unmarked_text, window, cx);
|
|
editor.change_selections(Default::default(), window, cx, |s| {
|
|
s.select_ranges(
|
|
selection_ranges
|
|
.into_iter()
|
|
.map(|range| MultiBufferOffset(range.start)..MultiBufferOffset(range.end)),
|
|
)
|
|
});
|
|
|
|
editor
|
|
});
|
|
|
|
let diff_view = workspace
|
|
.update_in(cx, |workspace, window, cx| {
|
|
TextDiffView::open(
|
|
&DiffClipboardWithSelectionData {
|
|
clipboard_text: clipboard_text.to_string(),
|
|
editor,
|
|
},
|
|
workspace,
|
|
window,
|
|
cx,
|
|
)
|
|
})
|
|
.unwrap()
|
|
.await
|
|
.unwrap();
|
|
|
|
cx.executor().run_until_parked();
|
|
|
|
assert_state_with_diff(
|
|
&diff_view.read_with(cx, |diff_view, cx| {
|
|
diff_view.diff_editor.read(cx).rhs_editor().clone()
|
|
}),
|
|
cx,
|
|
expected_diff,
|
|
);
|
|
|
|
diff_view.read_with(cx, |diff_view, cx| {
|
|
assert_eq!(diff_view.tab_content_text(0, cx), expected_tab_title);
|
|
assert_eq!(
|
|
diff_view.tab_tooltip_text(cx).unwrap(),
|
|
expected_tab_tooltip
|
|
);
|
|
});
|
|
}
|
|
}
|