1//! TextDiffView currently provides a UI for displaying differences between the clipboard and selected text.
2
3use anyhow::Result;
4use buffer_diff::BufferDiff;
5use editor::{
6 Editor, EditorEvent, EditorSettings, MultiBuffer, SplittableEditor, ToPoint,
7 actions::DiffClipboardWithSelectionData,
8};
9use futures::{FutureExt, select_biased};
10use gpui::{
11 AnyElement, App, AppContext as _, AsyncApp, Context, Entity, EventEmitter, FocusHandle,
12 Focusable, IntoElement, Render, Task, Window,
13};
14use language::{self, Buffer, Point};
15use project::Project;
16use settings::Settings;
17use std::{
18 any::{Any, TypeId},
19 cmp,
20 ops::Range,
21 pin::pin,
22 sync::Arc,
23 time::Duration,
24};
25use ui::{Color, Icon, IconName, Label, LabelCommon as _, SharedString};
26use util::paths::PathExt;
27
28use workspace::{
29 Item, ItemNavHistory, Workspace,
30 item::{ItemEvent, SaveOptions, TabContentParams},
31 searchable::SearchableItemHandle,
32};
33
34pub struct TextDiffView {
35 diff_editor: Entity<SplittableEditor>,
36 title: SharedString,
37 path: Option<SharedString>,
38 buffer_changes_tx: watch::Sender<()>,
39 _recalculate_diff_task: Task<Result<()>>,
40}
41
42const RECALCULATE_DIFF_DEBOUNCE: Duration = Duration::from_millis(250);
43
44impl TextDiffView {
45 pub fn open(
46 diff_data: &DiffClipboardWithSelectionData,
47 workspace: &Workspace,
48 window: &mut Window,
49 cx: &mut App,
50 ) -> Option<Task<Result<Entity<Self>>>> {
51 let source_editor = diff_data.editor.clone();
52
53 let selection_data = source_editor.update(cx, |editor, cx| {
54 let multibuffer = editor.buffer();
55 let selections = editor.selections.all::<Point>(&editor.display_snapshot(cx));
56 let first_selection = selections.first()?;
57
58 let (source_buffer, buffer_start, start_excerpt) = multibuffer
59 .read(cx)
60 .point_to_buffer_point(first_selection.start, cx)?;
61 let buffer_end = multibuffer
62 .read(cx)
63 .point_to_buffer_point(first_selection.end, cx)
64 .and_then(|(buf, pt, end_excerpt)| {
65 (buf.read(cx).remote_id() == source_buffer.read(cx).remote_id()
66 && end_excerpt == start_excerpt)
67 .then_some(pt)
68 })
69 .unwrap_or(buffer_start);
70
71 let buffer_snapshot = source_buffer.read(cx);
72 let max_point = buffer_snapshot.max_point();
73
74 if first_selection.is_empty() {
75 let full_range = Point::new(0, 0)..max_point;
76 return Some((source_buffer, full_range));
77 }
78
79 let expanded_start = Point::new(buffer_start.row, 0);
80 let expanded_end = if buffer_end.column > 0 {
81 let next_row = buffer_end.row + 1;
82 cmp::min(max_point, Point::new(next_row, 0))
83 } else {
84 buffer_end
85 };
86 Some((source_buffer, expanded_start..expanded_end))
87 });
88
89 let Some((source_buffer, expanded_selection_range)) = selection_data else {
90 log::warn!("There should always be at least one selection in Zed. This is a bug.");
91 return None;
92 };
93
94 source_editor.update(cx, |source_editor, cx| {
95 let multibuffer = source_editor.buffer();
96 let mb_range = {
97 let mb = multibuffer.read(cx);
98 let start_anchor =
99 mb.buffer_point_to_anchor(&source_buffer, expanded_selection_range.start, cx);
100 let end_anchor =
101 mb.buffer_point_to_anchor(&source_buffer, expanded_selection_range.end, cx);
102 start_anchor.zip(end_anchor).map(|(s, e)| {
103 let snapshot = mb.snapshot(cx);
104 s.to_point(&snapshot)..e.to_point(&snapshot)
105 })
106 };
107
108 if let Some(range) = mb_range {
109 source_editor.change_selections(Default::default(), window, cx, |s| {
110 s.select_ranges(vec![range]);
111 });
112 }
113 });
114
115 let source_buffer_snapshot = source_buffer.read(cx).snapshot();
116 let mut clipboard_text = diff_data.clipboard_text.clone();
117
118 if !clipboard_text.ends_with("\n") {
119 clipboard_text.push_str("\n");
120 }
121
122 let workspace = workspace.weak_handle();
123 let diff_buffer = cx.new(|cx| BufferDiff::new(&source_buffer_snapshot.text, cx));
124 let clipboard_buffer = build_clipboard_buffer(
125 clipboard_text,
126 &source_buffer,
127 expanded_selection_range.clone(),
128 cx,
129 );
130
131 let task = window.spawn(cx, async move |cx| {
132 update_diff_buffer(&diff_buffer, &source_buffer, &clipboard_buffer, cx).await?;
133
134 workspace.update_in(cx, |workspace, window, cx| {
135 let project = workspace.project().clone();
136 let workspace_entity = cx.entity();
137 let diff_view = cx.new(|cx| {
138 TextDiffView::new(
139 clipboard_buffer,
140 source_editor,
141 source_buffer,
142 expanded_selection_range,
143 diff_buffer,
144 project,
145 workspace_entity,
146 window,
147 cx,
148 )
149 });
150
151 let pane = workspace.active_pane();
152 pane.update(cx, |pane, cx| {
153 pane.add_item(Box::new(diff_view.clone()), true, true, None, window, cx);
154 });
155
156 diff_view
157 })
158 });
159
160 Some(task)
161 }
162
163 pub fn new(
164 clipboard_buffer: Entity<Buffer>,
165 source_editor: Entity<Editor>,
166 source_buffer: Entity<Buffer>,
167 source_range: Range<Point>,
168 diff_buffer: Entity<BufferDiff>,
169 project: Entity<Project>,
170 workspace: Entity<Workspace>,
171 window: &mut Window,
172 cx: &mut Context<Self>,
173 ) -> Self {
174 let multibuffer = cx.new(|cx| {
175 let mut multibuffer = MultiBuffer::new(language::Capability::ReadWrite);
176
177 multibuffer.set_excerpts_for_buffer(source_buffer.clone(), [source_range], 0, cx);
178
179 multibuffer.add_diff(diff_buffer.clone(), cx);
180 multibuffer
181 });
182 let diff_editor = cx.new(|cx| {
183 let splittable = SplittableEditor::new(
184 EditorSettings::get_global(cx).diff_view_style,
185 multibuffer,
186 project,
187 workspace,
188 window,
189 cx,
190 );
191 splittable.set_render_diff_hunk_controls(
192 Arc::new(|_, _, _, _, _, _, _, _| gpui::Empty.into_any_element()),
193 cx,
194 );
195 splittable.rhs_editor().update(cx, |editor, cx| {
196 editor.start_temporary_diff_override();
197 editor.disable_diagnostics(cx);
198 editor.set_expand_all_diff_hunks(cx);
199 });
200 splittable
201 });
202
203 let (buffer_changes_tx, mut buffer_changes_rx) = watch::channel(());
204
205 cx.subscribe(&source_buffer, move |this, _, event, _| match event {
206 language::BufferEvent::Edited { .. }
207 | language::BufferEvent::LanguageChanged(_)
208 | language::BufferEvent::Reparsed => {
209 this.buffer_changes_tx.send(()).ok();
210 }
211 _ => {}
212 })
213 .detach();
214
215 let editor = source_editor.read(cx);
216 let title = editor.buffer().read(cx).title(cx).to_string();
217 let selection_location_text = selection_location_text(editor, cx);
218 let selection_location_title = selection_location_text
219 .as_ref()
220 .map(|text| format!("{} @ {}", title, text))
221 .unwrap_or(title);
222
223 let path = editor
224 .buffer()
225 .read(cx)
226 .as_singleton()
227 .and_then(|b| {
228 b.read(cx)
229 .file()
230 .map(|f| f.full_path(cx).compact().to_string_lossy().into_owned())
231 })
232 .unwrap_or("untitled".into());
233
234 let selection_location_path = selection_location_text
235 .map(|text| format!("{} @ {}", path, text))
236 .unwrap_or(path);
237
238 Self {
239 diff_editor,
240 title: format!("Clipboard ↔ {selection_location_title}").into(),
241 path: Some(format!("Clipboard ↔ {selection_location_path}").into()),
242 buffer_changes_tx,
243 _recalculate_diff_task: cx.spawn(async move |_, cx| {
244 while buffer_changes_rx.recv().await.is_ok() {
245 loop {
246 let mut timer = cx
247 .background_executor()
248 .timer(RECALCULATE_DIFF_DEBOUNCE)
249 .fuse();
250 let mut recv = pin!(buffer_changes_rx.recv().fuse());
251 select_biased! {
252 _ = timer => break,
253 _ = recv => continue,
254 }
255 }
256
257 log::trace!("start recalculating");
258 update_diff_buffer(&diff_buffer, &source_buffer, &clipboard_buffer, cx).await?;
259 log::trace!("finish recalculating");
260 }
261 Ok(())
262 }),
263 }
264 }
265}
266
267fn build_clipboard_buffer(
268 text: String,
269 source_buffer: &Entity<Buffer>,
270 replacement_range: Range<Point>,
271 cx: &mut App,
272) -> Entity<Buffer> {
273 let source_buffer_snapshot = source_buffer.read(cx).snapshot();
274 cx.new(|cx| {
275 let mut buffer = language::Buffer::local(source_buffer_snapshot.text(), cx);
276 let language = source_buffer.read(cx).language().cloned();
277 buffer.set_language(language, cx);
278
279 let range_start = source_buffer_snapshot.point_to_offset(replacement_range.start);
280 let range_end = source_buffer_snapshot.point_to_offset(replacement_range.end);
281 buffer.edit([(range_start..range_end, text)], None, cx);
282
283 buffer
284 })
285}
286
287async fn update_diff_buffer(
288 diff: &Entity<BufferDiff>,
289 source_buffer: &Entity<Buffer>,
290 clipboard_buffer: &Entity<Buffer>,
291 cx: &mut AsyncApp,
292) -> Result<()> {
293 let source_buffer_snapshot = source_buffer.read_with(cx, |buffer, _| buffer.snapshot());
294 let language = source_buffer_snapshot.language().cloned();
295 let language_registry = source_buffer.read_with(cx, |buffer, _| buffer.language_registry());
296
297 let base_buffer_snapshot = clipboard_buffer.read_with(cx, |buffer, _| buffer.snapshot());
298 let base_text = base_buffer_snapshot.text();
299
300 let update = diff
301 .update(cx, |diff, cx| {
302 diff.update_diff(
303 source_buffer_snapshot.text.clone(),
304 Some(Arc::from(base_text.as_str())),
305 Some(true),
306 language.clone(),
307 cx,
308 )
309 })
310 .await;
311
312 diff.update(cx, |diff, cx| {
313 diff.language_changed(language, language_registry, cx);
314 diff.set_snapshot(update, &source_buffer_snapshot.text, cx)
315 })
316 .await;
317 Ok(())
318}
319
320impl EventEmitter<EditorEvent> for TextDiffView {}
321
322impl Focusable for TextDiffView {
323 fn focus_handle(&self, cx: &App) -> FocusHandle {
324 self.diff_editor.focus_handle(cx)
325 }
326}
327
328impl Item for TextDiffView {
329 type Event = EditorEvent;
330
331 fn tab_icon(&self, _window: &Window, _cx: &App) -> Option<Icon> {
332 Some(Icon::new(IconName::Diff).color(Color::Muted))
333 }
334
335 fn tab_content(&self, params: TabContentParams, _window: &Window, cx: &App) -> AnyElement {
336 Label::new(self.tab_content_text(params.detail.unwrap_or_default(), cx))
337 .color(if params.selected {
338 Color::Default
339 } else {
340 Color::Muted
341 })
342 .into_any_element()
343 }
344
345 fn tab_content_text(&self, _detail: usize, _: &App) -> SharedString {
346 self.title.clone()
347 }
348
349 fn tab_tooltip_text(&self, _: &App) -> Option<SharedString> {
350 self.path.clone()
351 }
352
353 fn to_item_events(event: &EditorEvent, f: &mut dyn FnMut(ItemEvent)) {
354 Editor::to_item_events(event, f)
355 }
356
357 fn telemetry_event_text(&self) -> Option<&'static str> {
358 Some("Selection Diff View Opened")
359 }
360
361 fn deactivated(&mut self, window: &mut Window, cx: &mut Context<Self>) {
362 self.diff_editor
363 .update(cx, |editor, cx| editor.deactivated(window, cx));
364 }
365
366 fn act_as_type<'a>(
367 &'a self,
368 type_id: TypeId,
369 self_handle: &'a Entity<Self>,
370 cx: &'a App,
371 ) -> Option<gpui::AnyEntity> {
372 if type_id == TypeId::of::<Self>() {
373 Some(self_handle.clone().into())
374 } else if type_id == TypeId::of::<SplittableEditor>() {
375 Some(self.diff_editor.clone().into())
376 } else if type_id == TypeId::of::<Editor>() {
377 Some(self.diff_editor.read(cx).rhs_editor().clone().into())
378 } else {
379 None
380 }
381 }
382
383 fn as_searchable(&self, _: &Entity<Self>, _: &App) -> Option<Box<dyn SearchableItemHandle>> {
384 Some(Box::new(self.diff_editor.clone()))
385 }
386
387 fn for_each_project_item(
388 &self,
389 cx: &App,
390 f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem),
391 ) {
392 self.diff_editor.read(cx).for_each_project_item(cx, f)
393 }
394
395 fn set_nav_history(
396 &mut self,
397 nav_history: ItemNavHistory,
398 _: &mut Window,
399 cx: &mut Context<Self>,
400 ) {
401 let rhs = self.diff_editor.read(cx).rhs_editor().clone();
402 rhs.update(cx, |editor, _| {
403 editor.set_nav_history(Some(nav_history));
404 });
405 }
406
407 fn navigate(
408 &mut self,
409 data: Arc<dyn Any + Send>,
410 window: &mut Window,
411 cx: &mut Context<Self>,
412 ) -> bool {
413 self.diff_editor
414 .update(cx, |editor, cx| editor.navigate(data, window, cx))
415 }
416
417 fn added_to_workspace(
418 &mut self,
419 workspace: &mut Workspace,
420 window: &mut Window,
421 cx: &mut Context<Self>,
422 ) {
423 self.diff_editor.update(cx, |editor, cx| {
424 editor.added_to_workspace(workspace, window, cx)
425 });
426 }
427
428 fn can_save(&self, cx: &App) -> bool {
429 // The editor handles the new buffer, so delegate to it
430 self.diff_editor.read(cx).can_save(cx)
431 }
432
433 fn save(
434 &mut self,
435 options: SaveOptions,
436 project: Entity<Project>,
437 window: &mut Window,
438 cx: &mut Context<Self>,
439 ) -> Task<Result<()>> {
440 // Delegate saving to the editor, which manages the new buffer
441 self.diff_editor
442 .update(cx, |editor, cx| editor.save(options, project, window, cx))
443 }
444}
445
446pub fn selection_location_text(editor: &Editor, cx: &App) -> Option<String> {
447 let buffer = editor.buffer().read(cx);
448 let buffer_snapshot = buffer.snapshot(cx);
449 let first_selection = editor.selections.disjoint_anchors().first()?;
450
451 let selection_start = first_selection.start.to_point(&buffer_snapshot);
452 let selection_end = first_selection.end.to_point(&buffer_snapshot);
453
454 let start_row = selection_start.row;
455 let start_column = selection_start.column;
456 let end_row = selection_end.row;
457 let end_column = selection_end.column;
458
459 let range_text = if start_row == end_row {
460 format!("L{}:{}-{}", start_row + 1, start_column + 1, end_column + 1)
461 } else {
462 format!(
463 "L{}:{}-L{}:{}",
464 start_row + 1,
465 start_column + 1,
466 end_row + 1,
467 end_column + 1
468 )
469 };
470
471 Some(range_text)
472}
473
474impl Render for TextDiffView {
475 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
476 self.diff_editor.clone()
477 }
478}
479
480#[cfg(test)]
481mod tests {
482 use super::*;
483 use editor::{MultiBufferOffset, PathKey, test::editor_test_context::assert_state_with_diff};
484 use gpui::{BorrowAppContext, TestAppContext, VisualContext};
485 use language::Point;
486 use project::{FakeFs, Project};
487 use serde_json::json;
488 use settings::{DiffViewStyle, SettingsStore};
489 use unindent::unindent;
490 use util::{path, test::marked_text_ranges};
491 use workspace::MultiWorkspace;
492
493 fn init_test(cx: &mut TestAppContext) {
494 cx.update(|cx| {
495 let settings_store = SettingsStore::test(cx);
496 cx.set_global(settings_store);
497 cx.update_global::<SettingsStore, _>(|store, cx| {
498 store.update_user_settings(cx, |settings| {
499 settings.editor.diff_view_style = Some(DiffViewStyle::Unified);
500 });
501 });
502 theme_settings::init(theme::LoadThemes::JustBase, cx);
503 });
504 }
505
506 #[gpui::test]
507 async fn test_diffing_clipboard_against_empty_selection_uses_full_buffer_selection(
508 cx: &mut TestAppContext,
509 ) {
510 base_test(
511 path!("/test"),
512 path!("/test/text.txt"),
513 "def process_incoming_inventory(items, warehouse_id):\n pass\n",
514 "def process_outgoing_inventory(items, warehouse_id):\n passˇ\n",
515 &unindent(
516 "
517 - def process_incoming_inventory(items, warehouse_id):
518 + ˇdef process_outgoing_inventory(items, warehouse_id):
519 pass
520 ",
521 ),
522 "Clipboard ↔ text.txt @ L1:1-L3:1",
523 &format!("Clipboard ↔ {} @ L1:1-L3:1", path!("test/text.txt")),
524 cx,
525 )
526 .await;
527 }
528
529 #[gpui::test]
530 async fn test_diffing_clipboard_against_multiline_selection_expands_to_full_lines(
531 cx: &mut TestAppContext,
532 ) {
533 base_test(
534 path!("/test"),
535 path!("/test/text.txt"),
536 "def process_incoming_inventory(items, warehouse_id):\n pass\n",
537 "«def process_outgoing_inventory(items, warehouse_id):\n passˇ»\n",
538 &unindent(
539 "
540 - def process_incoming_inventory(items, warehouse_id):
541 + ˇdef process_outgoing_inventory(items, warehouse_id):
542 pass
543 ",
544 ),
545 "Clipboard ↔ text.txt @ L1:1-L3:1",
546 &format!("Clipboard ↔ {} @ L1:1-L3:1", path!("test/text.txt")),
547 cx,
548 )
549 .await;
550 }
551
552 #[gpui::test]
553 async fn test_diffing_clipboard_against_single_line_selection(cx: &mut TestAppContext) {
554 base_test(
555 path!("/test"),
556 path!("/test/text.txt"),
557 "a",
558 "«bbˇ»",
559 &unindent(
560 "
561 - a
562 + ˇbb",
563 ),
564 "Clipboard ↔ text.txt @ L1:1-3",
565 &format!("Clipboard ↔ {} @ L1:1-3", path!("test/text.txt")),
566 cx,
567 )
568 .await;
569 }
570
571 #[gpui::test]
572 async fn test_diffing_clipboard_with_leading_whitespace_against_line(cx: &mut TestAppContext) {
573 base_test(
574 path!("/test"),
575 path!("/test/text.txt"),
576 " a",
577 "«bbˇ»",
578 &unindent(
579 "
580 - a
581 + ˇbb",
582 ),
583 "Clipboard ↔ text.txt @ L1:1-3",
584 &format!("Clipboard ↔ {} @ L1:1-3", path!("test/text.txt")),
585 cx,
586 )
587 .await;
588 }
589
590 #[gpui::test]
591 async fn test_diffing_clipboard_against_line_with_leading_whitespace(cx: &mut TestAppContext) {
592 base_test(
593 path!("/test"),
594 path!("/test/text.txt"),
595 "a",
596 " «bbˇ»",
597 &unindent(
598 "
599 - a
600 + ˇ bb",
601 ),
602 "Clipboard ↔ text.txt @ L1:1-7",
603 &format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
604 cx,
605 )
606 .await;
607 }
608
609 #[gpui::test]
610 async fn test_diffing_clipboard_against_line_with_leading_whitespace_included_in_selection(
611 cx: &mut TestAppContext,
612 ) {
613 base_test(
614 path!("/test"),
615 path!("/test/text.txt"),
616 "a",
617 "« bbˇ»",
618 &unindent(
619 "
620 - a
621 + ˇ bb",
622 ),
623 "Clipboard ↔ text.txt @ L1:1-7",
624 &format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
625 cx,
626 )
627 .await;
628 }
629
630 #[gpui::test]
631 async fn test_diffing_clipboard_with_leading_whitespace_against_line_with_leading_whitespace(
632 cx: &mut TestAppContext,
633 ) {
634 base_test(
635 path!("/test"),
636 path!("/test/text.txt"),
637 " a",
638 " «bbˇ»",
639 &unindent(
640 "
641 - a
642 + ˇ bb",
643 ),
644 "Clipboard ↔ text.txt @ L1:1-7",
645 &format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
646 cx,
647 )
648 .await;
649 }
650
651 #[gpui::test]
652 async fn test_diffing_clipboard_with_leading_whitespace_against_line_with_leading_whitespace_included_in_selection(
653 cx: &mut TestAppContext,
654 ) {
655 base_test(
656 path!("/test"),
657 path!("/test/text.txt"),
658 " a",
659 "« bbˇ»",
660 &unindent(
661 "
662 - a
663 + ˇ bb",
664 ),
665 "Clipboard ↔ text.txt @ L1:1-7",
666 &format!("Clipboard ↔ {} @ L1:1-7", path!("test/text.txt")),
667 cx,
668 )
669 .await;
670 }
671
672 #[gpui::test]
673 async fn test_diffing_clipboard_against_partial_selection_expands_to_include_trailing_characters(
674 cx: &mut TestAppContext,
675 ) {
676 base_test(
677 path!("/test"),
678 path!("/test/text.txt"),
679 "a",
680 "«bˇ»b",
681 &unindent(
682 "
683 - a
684 + ˇbb",
685 ),
686 "Clipboard ↔ text.txt @ L1:1-3",
687 &format!("Clipboard ↔ {} @ L1:1-3", path!("test/text.txt")),
688 cx,
689 )
690 .await;
691 }
692
693 #[gpui::test]
694 async fn test_diffing_clipboard_from_multibuffer_with_selection(cx: &mut TestAppContext) {
695 init_test(cx);
696
697 let fs = FakeFs::new(cx.executor());
698 fs.insert_tree(
699 path!("/project"),
700 json!({
701 "a.txt": "alpha\nbeta\ngamma",
702 "b.txt": "one\ntwo\nthree"
703 }),
704 )
705 .await;
706
707 let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
708
709 let buffer_a = project
710 .update(cx, |project, cx| {
711 project.open_local_buffer(path!("/project/a.txt"), cx)
712 })
713 .await
714 .unwrap();
715 let buffer_b = project
716 .update(cx, |project, cx| {
717 project.open_local_buffer(path!("/project/b.txt"), cx)
718 })
719 .await
720 .unwrap();
721
722 let (multi_workspace, cx) =
723 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
724 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
725
726 let editor = cx.new_window_entity(|window, cx| {
727 let multibuffer = cx.new(|cx| {
728 let mut mb = MultiBuffer::new(language::Capability::ReadWrite);
729 mb.set_excerpts_for_path(
730 PathKey::sorted(0),
731 buffer_a.clone(),
732 [Point::new(0, 0)..Point::new(2, 5)],
733 0,
734 cx,
735 );
736 mb.set_excerpts_for_path(
737 PathKey::sorted(1),
738 buffer_b.clone(),
739 [Point::new(0, 0)..Point::new(2, 5)],
740 0,
741 cx,
742 );
743 mb
744 });
745
746 let mut editor =
747 Editor::for_multibuffer(multibuffer, Some(project.clone()), window, cx);
748 // Select "beta" inside the first excerpt
749 editor.change_selections(Default::default(), window, cx, |s| {
750 s.select_ranges([MultiBufferOffset(6)..MultiBufferOffset(10)]);
751 });
752 editor
753 });
754
755 let diff_view = workspace
756 .update_in(cx, |workspace, window, cx| {
757 TextDiffView::open(
758 &DiffClipboardWithSelectionData {
759 clipboard_text: "REPLACED".to_string(),
760 editor,
761 },
762 workspace,
763 window,
764 cx,
765 )
766 })
767 .unwrap()
768 .await
769 .unwrap();
770
771 cx.executor().run_until_parked();
772
773 diff_view.read_with(cx, |diff_view, _cx| {
774 assert!(
775 diff_view.title.contains("Clipboard"),
776 "diff view should have opened with a clipboard diff title, got: {}",
777 diff_view.title
778 );
779 });
780 }
781
782 #[gpui::test]
783 async fn test_diffing_clipboard_from_multibuffer_with_empty_selection(cx: &mut TestAppContext) {
784 init_test(cx);
785
786 let fs = FakeFs::new(cx.executor());
787 fs.insert_tree(
788 path!("/project"),
789 json!({
790 "a.txt": "alpha\nbeta\ngamma",
791 "b.txt": "one\ntwo\nthree"
792 }),
793 )
794 .await;
795
796 let project = Project::test(fs, [path!("/project").as_ref()], cx).await;
797
798 let buffer_a = project
799 .update(cx, |project, cx| {
800 project.open_local_buffer(path!("/project/a.txt"), cx)
801 })
802 .await
803 .unwrap();
804 let buffer_b = project
805 .update(cx, |project, cx| {
806 project.open_local_buffer(path!("/project/b.txt"), cx)
807 })
808 .await
809 .unwrap();
810
811 let (multi_workspace, cx) =
812 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
813 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
814
815 let editor = cx.new_window_entity(|window, cx| {
816 let multibuffer = cx.new(|cx| {
817 let mut mb = MultiBuffer::new(language::Capability::ReadWrite);
818 mb.set_excerpts_for_path(
819 PathKey::sorted(0),
820 buffer_a.clone(),
821 [Point::new(0, 0)..Point::new(2, 5)],
822 0,
823 cx,
824 );
825 mb.set_excerpts_for_path(
826 PathKey::sorted(1),
827 buffer_b.clone(),
828 [Point::new(0, 0)..Point::new(2, 5)],
829 0,
830 cx,
831 );
832 mb
833 });
834
835 let mut editor =
836 Editor::for_multibuffer(multibuffer, Some(project.clone()), window, cx);
837 // Cursor inside the first excerpt (no selection)
838 editor.change_selections(Default::default(), window, cx, |s| {
839 s.select_ranges([MultiBufferOffset(6)..MultiBufferOffset(6)]);
840 });
841 editor
842 });
843
844 let diff_view = workspace
845 .update_in(cx, |workspace, window, cx| {
846 TextDiffView::open(
847 &DiffClipboardWithSelectionData {
848 clipboard_text: "REPLACED".to_string(),
849 editor,
850 },
851 workspace,
852 window,
853 cx,
854 )
855 })
856 .unwrap()
857 .await
858 .unwrap();
859
860 cx.executor().run_until_parked();
861
862 // Empty selection should diff the full underlying buffer
863 diff_view.read_with(cx, |diff_view, _cx| {
864 assert!(
865 diff_view.title.contains("Clipboard"),
866 "diff view should have opened with a clipboard diff title, got: {}",
867 diff_view.title
868 );
869 });
870 }
871
872 async fn base_test(
873 project_root: &str,
874 file_path: &str,
875 clipboard_text: &str,
876 editor_text: &str,
877 expected_diff: &str,
878 expected_tab_title: &str,
879 expected_tab_tooltip: &str,
880 cx: &mut TestAppContext,
881 ) {
882 init_test(cx);
883
884 let file_name = std::path::Path::new(file_path)
885 .file_name()
886 .unwrap()
887 .to_str()
888 .unwrap();
889
890 let fs = FakeFs::new(cx.executor());
891 fs.insert_tree(
892 project_root,
893 json!({
894 file_name: editor_text
895 }),
896 )
897 .await;
898
899 let project = Project::test(fs, [project_root.as_ref()], cx).await;
900
901 let (multi_workspace, cx) =
902 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
903 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
904
905 let buffer = project
906 .update(cx, |project, cx| project.open_local_buffer(file_path, cx))
907 .await
908 .unwrap();
909
910 let editor = cx.new_window_entity(|window, cx| {
911 let mut editor = Editor::for_buffer(buffer, None, window, cx);
912 let (unmarked_text, selection_ranges) = marked_text_ranges(editor_text, false);
913 editor.set_text(unmarked_text, window, cx);
914 editor.change_selections(Default::default(), window, cx, |s| {
915 s.select_ranges(
916 selection_ranges
917 .into_iter()
918 .map(|range| MultiBufferOffset(range.start)..MultiBufferOffset(range.end)),
919 )
920 });
921
922 editor
923 });
924
925 let diff_view = workspace
926 .update_in(cx, |workspace, window, cx| {
927 TextDiffView::open(
928 &DiffClipboardWithSelectionData {
929 clipboard_text: clipboard_text.to_string(),
930 editor,
931 },
932 workspace,
933 window,
934 cx,
935 )
936 })
937 .unwrap()
938 .await
939 .unwrap();
940
941 cx.executor().run_until_parked();
942
943 assert_state_with_diff(
944 &diff_view.read_with(cx, |diff_view, cx| {
945 diff_view.diff_editor.read(cx).rhs_editor().clone()
946 }),
947 cx,
948 expected_diff,
949 );
950
951 diff_view.read_with(cx, |diff_view, cx| {
952 assert_eq!(diff_view.tab_content_text(0, cx), expected_tab_title);
953 assert_eq!(
954 diff_view.tab_tooltip_text(cx).unwrap(),
955 expected_tab_tooltip
956 );
957 });
958 }
959}